feat: add OKX options module with dual API, USDT/USDC convert, and docs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -100,6 +100,25 @@ OKX_POSITION_INST_TYPE=SWAP
|
|||||||
# 企业微信推送里展示的账户备注
|
# 企业微信推送里展示的账户备注
|
||||||
# OKX_ACCOUNT_LABEL=
|
# OKX_ACCOUNT_LABEL=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 期权(主账户 API,与永续子账户 OKX_API_* 分离;修改后须重启 PM2)
|
||||||
|
# 详见 docs/期权方案.md 与 docs/期权用法.md
|
||||||
|
# =============================================================================
|
||||||
|
OKX_OPTIONS_ENABLED=false
|
||||||
|
OKX_OPTIONS_API_KEY=
|
||||||
|
OKX_OPTIONS_API_SECRET=
|
||||||
|
OKX_OPTIONS_API_PASSPHRASE=
|
||||||
|
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
|
||||||
|
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||||
|
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||||
|
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
|
||||||
|
OKX_OPTIONS_MAX_DTE_DAYS=2
|
||||||
|
OKX_OPTIONS_ITM_MAX_DIST_USD=30
|
||||||
|
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
|
||||||
|
OKX_OPTIONS_POLL_SECONDS=15
|
||||||
|
OKX_OPTIONS_TD_MODE=cross
|
||||||
|
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)
|
# 关键位程序自动下单(与 POSITION_SIZING_MODE 联动,修改后须重启 PM2)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "tr
|
|||||||
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
|
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
|
||||||
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
|
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
|
||||||
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
|
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
|
||||||
|
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
|
||||||
|
OKX_OPTIONS_API_KEY = os.getenv("OKX_OPTIONS_API_KEY", "")
|
||||||
|
OKX_OPTIONS_API_SECRET = os.getenv("OKX_OPTIONS_API_SECRET", "")
|
||||||
|
OKX_OPTIONS_API_PASSPHRASE = os.getenv("OKX_OPTIONS_API_PASSPHRASE", "")
|
||||||
|
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
|
||||||
|
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
|
||||||
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
|
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
|
||||||
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
|
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
|
||||||
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
|
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
|
||||||
@@ -462,6 +468,20 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
|
|||||||
exchange.apiKey = OKX_API_KEY
|
exchange.apiKey = OKX_API_KEY
|
||||||
exchange.secret = OKX_API_SECRET
|
exchange.secret = OKX_API_SECRET
|
||||||
exchange.password = OKX_API_PASSPHRASE
|
exchange.password = OKX_API_PASSPHRASE
|
||||||
|
|
||||||
|
exchange_options = ccxt.okx(
|
||||||
|
{
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {"defaultType": "option"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if OKX_CCXT_PROXIES:
|
||||||
|
exchange_options.proxies = OKX_CCXT_PROXIES
|
||||||
|
if OKX_OPTIONS_API_KEY and OKX_OPTIONS_API_SECRET and OKX_OPTIONS_API_PASSPHRASE:
|
||||||
|
exchange_options.apiKey = OKX_OPTIONS_API_KEY
|
||||||
|
exchange_options.secret = OKX_OPTIONS_API_SECRET
|
||||||
|
exchange_options.password = OKX_OPTIONS_API_PASSPHRASE
|
||||||
|
|
||||||
MARKETS_LOADED = False
|
MARKETS_LOADED = False
|
||||||
ACCOUNT_BALANCE_CACHE = {
|
ACCOUNT_BALANCE_CACHE = {
|
||||||
"updated_at": 0.0,
|
"updated_at": 0.0,
|
||||||
@@ -1467,8 +1487,10 @@ def init_db():
|
|||||||
)
|
)
|
||||||
|
|
||||||
from lib.strategy.strategy_db import init_strategy_tables
|
from lib.strategy.strategy_db import init_strategy_tables
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
|
||||||
init_strategy_tables(conn)
|
init_strategy_tables(conn)
|
||||||
|
init_options_tables(conn)
|
||||||
from lib.trade.account_risk_lib import ensure_account_risk_schema
|
from lib.trade.account_risk_lib import ensure_account_risk_schema
|
||||||
|
|
||||||
ensure_account_risk_schema(conn)
|
ensure_account_risk_schema(conn)
|
||||||
@@ -6660,6 +6682,9 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
key_rule_ctx=key_rule_ctx,
|
key_rule_ctx=key_rule_ctx,
|
||||||
funds_fmt=format_funds_u,
|
funds_fmt=format_funds_u,
|
||||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||||
|
options_enabled=OKX_OPTIONS_ENABLED,
|
||||||
|
options_trade_budget=OKX_OPTIONS_TRADE_BUDGET_USDC,
|
||||||
|
options_default_underly=OKX_OPTIONS_DEFAULT_UNDERLY,
|
||||||
risk_status=risk_status,
|
risk_status=risk_status,
|
||||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||||
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||||
@@ -6739,6 +6764,15 @@ def settings_page():
|
|||||||
return render_main_page("settings")
|
return render_main_page("settings")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/options")
|
||||||
|
@login_required
|
||||||
|
def options_main_page():
|
||||||
|
if not OKX_OPTIONS_ENABLED:
|
||||||
|
flash("期权模块未启用,请在 .env 设置 OKX_OPTIONS_ENABLED=true")
|
||||||
|
return redirect(url_for("trade_page"))
|
||||||
|
return render_main_page("options")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/account_snapshot")
|
@app.route("/api/account_snapshot")
|
||||||
@login_required
|
@login_required
|
||||||
def api_account_snapshot():
|
def api_account_snapshot():
|
||||||
@@ -8802,6 +8836,7 @@ _REPO_STATIC_DIR = common_static_dir(os.path.dirname(BASE_DIR))
|
|||||||
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
|
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
|
||||||
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
|
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
|
||||||
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
|
_MANUAL_ORDER_RR_PREVIEW_JS = os.path.join(_REPO_STATIC_DIR, "manual_order_rr_preview.js")
|
||||||
|
_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/static/ai_review_render.js")
|
@app.route("/static/ai_review_render.js")
|
||||||
@@ -8825,6 +8860,13 @@ def static_manual_order_rr_preview_js():
|
|||||||
return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8")
|
return send_file(_MANUAL_ORDER_RR_PREVIEW_JS, mimetype="application/javascript; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/static/options_panel.js")
|
||||||
|
def static_options_panel_js():
|
||||||
|
if not os.path.isfile(_OPTIONS_PANEL_JS):
|
||||||
|
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
|
||||||
|
return send_file(_OPTIONS_PANEL_JS, mimetype="application/javascript; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/export/review_md/<rid>")
|
@app.route("/export/review_md/<rid>")
|
||||||
@login_required
|
@login_required
|
||||||
def export_review_md(rid):
|
def export_review_md(rid):
|
||||||
@@ -9276,6 +9318,10 @@ from lib.strategy.strategy_trend_register import install_strategy_trend
|
|||||||
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
install_strategy_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
install_strategy_trend(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
|
||||||
|
from lib.options.options_register import install_options_trading
|
||||||
|
|
||||||
|
install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
|
||||||
|
|
||||||
_purge_key_monitors_if_full_margin()
|
_purge_key_monitors_if_full_margin()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# OKX 期权模块 — 技术方案
|
||||||
|
|
||||||
|
> 适用范围:`crypto_monitor_okx` 实例;与永续子账户并行,不新增 PM2 进程。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在现有 OKX 监控实例中增加 **USDⓈ 本位期权(买方)** 能力:
|
||||||
|
|
||||||
|
- 永续/关键位:继续走 **子账户 API-A**(现有 `OKX_API_*`)
|
||||||
|
- 期权:走 **主账户 API-B**(`OKX_OPTIONS_API_*`)
|
||||||
|
- 资金展示对齐 OKX:**资金账户 / 交易账户**,分币种显示 USDT、USDC、USDG
|
||||||
|
- 支持 **手动 USDT→USDC 兑换** 与 **USDC 账户划转**
|
||||||
|
- **无总资金池上限**;单笔权利金上限可配置(默认 10 USDC)
|
||||||
|
|
||||||
|
## 2. 交易规则(硬约束)
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 仅买方 | 开仓 `buy`,平仓 `sell`;禁止卖方开仓 |
|
||||||
|
| 产品 | `BTC-USD_UM` / `ETH-USD_UM`(线性、USDC/USDG 结算) |
|
||||||
|
| 到期 | 仅展示 ≤2 日到期合约(可配置 `OKX_OPTIONS_MAX_DTE_DAYS`) |
|
||||||
|
| 虚实 | 仅 **轻度实值**(`OKX_OPTIONS_ITM_ONLY`) |
|
||||||
|
| 合约规格 | **1 张 = 0.01 ETH/BTC**(`ctMult=0.01`,以接口为准) |
|
||||||
|
| 报价单位 | 盘口 ask/bid = **每 1 ETH/BTC** 的 USD 价 |
|
||||||
|
| 权利金 | `总权利金 = 报价 × ETH数量`;`张数 = ETH数量 / 0.01` |
|
||||||
|
| 单笔预算 | `≤ OKX_OPTIONS_TRADE_BUDGET_USDC`(默认 10),算张数 × `OKX_OPTIONS_BUDGET_BUFFER`(默认 0.95) |
|
||||||
|
| 开仓 | 限价买单,价格 = 卖一 |
|
||||||
|
| 平仓 | 限价卖单,价格 = 买一(市价需显式开启且二次确认) |
|
||||||
|
| 监控 | 浮盈 / 已付权利金 ≥ 100% → 企业微信推送一次 |
|
||||||
|
|
||||||
|
## 3. 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
crypto_okx(单 PM2)
|
||||||
|
├── exchange (swap) ← OKX_API_* 子账户
|
||||||
|
└── exchange_options ← OKX_OPTIONS_API_* 主账户
|
||||||
|
|
||||||
|
lib/options/
|
||||||
|
├── okx_options_lib.py # 封装于 lib/exchange/
|
||||||
|
├── options_pricing_lib.py
|
||||||
|
├── options_db.py
|
||||||
|
├── options_monitor_lib.py
|
||||||
|
└── options_register.py # 路由 + 监控线程
|
||||||
|
```
|
||||||
|
|
||||||
|
**隔离:** 期权模块只调用 `exchange_options`;永续逻辑只调用 `exchange`。
|
||||||
|
|
||||||
|
## 4. 资金与兑换
|
||||||
|
|
||||||
|
### 4.1 展示(期权页顶栏)
|
||||||
|
|
||||||
|
| 账户 | 币种 |
|
||||||
|
|------|------|
|
||||||
|
| 资金账户 | USDT、USDC(若有) |
|
||||||
|
| 交易账户 | USDT、USDC、USDG(若有) |
|
||||||
|
|
||||||
|
不展示「练手池」等抽象记账名称。
|
||||||
|
|
||||||
|
### 4.2 推荐操作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
资金账户 USDT
|
||||||
|
→ [手动兑换 USDT→USDC](OKX Convert API,资金账户内)
|
||||||
|
→ [划转到交易账户](USDC)
|
||||||
|
→ 交易账户 USDC
|
||||||
|
→ [限价买入期权]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 API
|
||||||
|
|
||||||
|
| 接口 | OKX |
|
||||||
|
|------|-----|
|
||||||
|
| 余额 | `fetch_balance`(funding / trading)+ `GET /api/v5/asset/balances` |
|
||||||
|
| 询价兑换 | `POST /api/v5/asset/convert/estimate-quote` |
|
||||||
|
| 确认兑换 | `POST /api/v5/asset/convert/trade` |
|
||||||
|
| 划转 | `exchange.transfer(ccy, amt, from, to)` |
|
||||||
|
|
||||||
|
## 5. 配置项(`.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OKX_OPTIONS_ENABLED=false
|
||||||
|
OKX_OPTIONS_API_KEY=
|
||||||
|
OKX_OPTIONS_API_SECRET=
|
||||||
|
OKX_OPTIONS_API_PASSPHRASE=
|
||||||
|
OKX_OPTIONS_ACCOUNT_LABEL=主账户·期权
|
||||||
|
|
||||||
|
OKX_OPTIONS_TRADE_BUDGET_USDC=10
|
||||||
|
OKX_OPTIONS_BUDGET_BUFFER=0.95
|
||||||
|
OKX_OPTIONS_DEFAULT_UNDERLY=ETH
|
||||||
|
OKX_OPTIONS_MAX_DTE_DAYS=2
|
||||||
|
OKX_OPTIONS_ITM_MAX_DIST_USD=30
|
||||||
|
OKX_OPTIONS_PROFIT_ALERT_RATIO=1.0
|
||||||
|
OKX_OPTIONS_POLL_SECONDS=15
|
||||||
|
OKX_OPTIONS_TD_MODE=cross
|
||||||
|
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `.env` 后须 `pm2 restart crypto_okx`。
|
||||||
|
|
||||||
|
## 6. 数据库
|
||||||
|
|
||||||
|
### `options_trades`
|
||||||
|
|
||||||
|
记录本地开仓/平仓、权利金、翻倍提醒状态。
|
||||||
|
|
||||||
|
### `options_convert_log` / `options_transfer_log`
|
||||||
|
|
||||||
|
可选记录兑换与划转操作。
|
||||||
|
|
||||||
|
## 7. HTTP 路由
|
||||||
|
|
||||||
|
| 方法 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| GET | `/options` |
|
||||||
|
| GET | `/api/options/balances` |
|
||||||
|
| GET | `/api/options/chain` |
|
||||||
|
| GET | `/api/options/quote` |
|
||||||
|
| POST | `/api/options/open` |
|
||||||
|
| POST | `/api/options/close` |
|
||||||
|
| POST | `/api/options/convert/quote` |
|
||||||
|
| POST | `/api/options/convert/execute` |
|
||||||
|
| POST | `/api/options/transfer` |
|
||||||
|
| GET | `/api/options/positions` |
|
||||||
|
|
||||||
|
## 8. 分阶段交付
|
||||||
|
|
||||||
|
1. **基础设施**:双 API、余额、文档、设置页说明
|
||||||
|
2. **兑换 + 划转**:资金账户 USDT→USDC、划转到交易户
|
||||||
|
3. **交易**:链、报价、开平仓、持仓
|
||||||
|
4. **监控**:翻倍微信提醒
|
||||||
|
|
||||||
|
## 9. 不在一期范围
|
||||||
|
|
||||||
|
- 卖方、组合单、RFQ
|
||||||
|
- 自动 USDT↔USDC
|
||||||
|
- `manual-agent-okx` / 中控聚合
|
||||||
|
- 币本位期权
|
||||||
|
|
||||||
|
## 10. 安全
|
||||||
|
|
||||||
|
- 期权 API:**交易 + 读**,禁止提币
|
||||||
|
- 日志不输出 Secret
|
||||||
|
- 下单前校验 `client is exchange_options`
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# OKX 期权 — 使用说明
|
||||||
|
|
||||||
|
## 1. 前置条件
|
||||||
|
|
||||||
|
1. OKX **主账户**已开通期权(USDⓈ 本位),且 App 中可见 `ETHUSD UM` / `BTCUSD UM`。
|
||||||
|
2. 在 `crypto_monitor_okx/.env` 配置 **期权专用 API**(与永续子账户分开):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OKX_OPTIONS_ENABLED=true
|
||||||
|
OKX_OPTIONS_API_KEY=你的主账户Key
|
||||||
|
OKX_OPTIONS_API_SECRET=...
|
||||||
|
OKX_OPTIONS_API_PASSPHRASE=...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 重启实例:`pm2 restart crypto_okx`
|
||||||
|
|
||||||
|
> 永续仍用原有 `OKX_API_*`(子账户);期权只用 `OKX_OPTIONS_API_*`(主账户)。
|
||||||
|
|
||||||
|
## 2. 资金准备
|
||||||
|
|
||||||
|
期权权利金使用 **USDC 或 USDG**,不能直接用 USDT 买入。
|
||||||
|
|
||||||
|
### 推荐步骤
|
||||||
|
|
||||||
|
1. 打开 **期权** 页,查看顶栏:
|
||||||
|
- **资金账户**:USDT 余额
|
||||||
|
- **交易账户**:USDC 余额(买期权从这里扣)
|
||||||
|
2. **币种兑换**(资金账户内)
|
||||||
|
- 从 USDT 兑换为 USDC
|
||||||
|
- 先点 **询价**,确认预估获得量后点 **确认兑换**
|
||||||
|
3. **账户划转**
|
||||||
|
- 从:资金账户 → 到:交易账户
|
||||||
|
- 币种:USDC
|
||||||
|
- 将兑换得到的 USDC 划到交易账户
|
||||||
|
4. 确认 **交易账户 USDC** 足够支付本笔权利金
|
||||||
|
|
||||||
|
系统 **不会** 自动兑换或划转,避免误动资金。
|
||||||
|
|
||||||
|
## 3. 下单流程
|
||||||
|
|
||||||
|
1. 顶栏进入 **期权**
|
||||||
|
2. 选择 **ETH** 或 **BTC**
|
||||||
|
3. 选择 **到期日**(默认仅 1~2 日)
|
||||||
|
4. 选择 **看涨 Call** 或 **看跌 Put**
|
||||||
|
5. 在行权价列表中选 **轻度实值** 合约
|
||||||
|
6. 查看:
|
||||||
|
- **卖一价**(每 1 ETH/BTC 的报价)
|
||||||
|
- **张数 / ETH 数量**
|
||||||
|
- **预估权利金**(USDC)
|
||||||
|
7. 选择 **按预算打满**(默认 10U×0.95)或 **指定 ETH 数量**
|
||||||
|
8. 点击 **限价买入**(价格 = 卖一)
|
||||||
|
|
||||||
|
### 张数说明
|
||||||
|
|
||||||
|
- **1 张 = 0.01 ETH**(或 0.01 BTC)— 与 OKX App「合约价值」一致
|
||||||
|
- 盘口报价是 **每 1 ETH** 的价格
|
||||||
|
例:报价 15.6,买 0.5 ETH(50 张)→ 权利金 ≈ 15.6 × 0.5 = **7.8 USDC**
|
||||||
|
|
||||||
|
## 4. 持仓与平仓
|
||||||
|
|
||||||
|
持仓表字段对齐 OKX:合约、张数、开仓均价、标记价、浮盈、收益率、到期等。
|
||||||
|
|
||||||
|
**平仓(锁利/止损):**
|
||||||
|
|
||||||
|
1. 在持仓行点击 **平仓**
|
||||||
|
2. 查看 **买一价** 与预估收回
|
||||||
|
3. 确认 **限价卖出**(价格 = 买一)
|
||||||
|
|
||||||
|
> 默认不使用市价平仓。若 `.env` 开启 `OKX_OPTIONS_ALLOW_MARKET_CLOSE=true`,市价按钮会出现并带风险提示。
|
||||||
|
|
||||||
|
## 5. 微信提醒
|
||||||
|
|
||||||
|
当某笔持仓 **未实现盈亏 ≥ 已付权利金的 100%**(翻倍)时,会发 **一条** 企业微信提醒(同一笔只提醒一次)。
|
||||||
|
|
||||||
|
需已配置 `WECHAT_WEBHOOK`。
|
||||||
|
|
||||||
|
## 6. 与永续的关系
|
||||||
|
|
||||||
|
| | 永续(子账户) | 期权(主账户) |
|
||||||
|
|--|----------------|----------------|
|
||||||
|
| API | `OKX_API_*` | `OKX_OPTIONS_API_*` |
|
||||||
|
| 页面 | 实盘下单 / 关键位 | 期权 |
|
||||||
|
| 资金顶栏 | USDT 资金户+交易户 | 期权页单独显示 USDC 等 |
|
||||||
|
|
||||||
|
两套资金 **不合并** 显示。
|
||||||
|
|
||||||
|
## 7. 配置说明
|
||||||
|
|
||||||
|
| 变量 | 默认 | 含义 |
|
||||||
|
|------|------|------|
|
||||||
|
| `OKX_OPTIONS_TRADE_BUDGET_USDC` | 10 | 单笔权利金上限 |
|
||||||
|
| `OKX_OPTIONS_BUDGET_BUFFER` | 0.95 | 算张数时预留 5% 缓冲 |
|
||||||
|
| `OKX_OPTIONS_MAX_DTE_DAYS` | 2 | 最多选几天内到期 |
|
||||||
|
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
|
||||||
|
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
|
||||||
|
|
||||||
|
## 8. 常见问题
|
||||||
|
|
||||||
|
**Q:为什么买不了?**
|
||||||
|
- 交易账户 USDC 不足 → 先兑换再划转
|
||||||
|
- 卖一价过高,10U 预算买不到 1 张 → 选更便宜合约或提高 `OKX_OPTIONS_TRADE_BUDGET_USDC`
|
||||||
|
- 期权 API 未配置或 `OKX_OPTIONS_ENABLED=false`
|
||||||
|
|
||||||
|
**Q:报价 15 是每张 15U 吗?**
|
||||||
|
- 不是。15 是 **每 1 ETH** 的报价;每张(0.01 ETH)约 0.15 USDC。
|
||||||
|
|
||||||
|
**Q:子账户能开期权吗?**
|
||||||
|
- 本系统期权走主账户 API;子账户永续不受影响。
|
||||||
|
|
||||||
|
## 9. 风险说明
|
||||||
|
|
||||||
|
- 买方最大亏损为 **权利金**;近期实值仍会时间衰减
|
||||||
|
- 限价单可能因无流动性未成交
|
||||||
|
- 请先在小额下验证兑换、划转、开平仓全流程
|
||||||
@@ -2326,3 +2326,68 @@ html[data-theme="light"] .settings-export-link {
|
|||||||
color: #1d4f8c;
|
color: #1d4f8c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* OKX 期权页 */
|
||||||
|
.options-funds-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
}
|
||||||
|
.options-funds-col {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.options-fund-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.options-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.options-section.card-nested {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.options-strike-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.options-strike-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.options-strike-table th,
|
||||||
|
.options-strike-table td {
|
||||||
|
padding: 8px 6px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.options-chain-toolbar .btn-secondary.active,
|
||||||
|
.opt-uly-btn.active,
|
||||||
|
.opt-type-btn.active {
|
||||||
|
border-color: #4a7cff;
|
||||||
|
color: #9ec0ff;
|
||||||
|
}
|
||||||
|
.options-order-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.options-order-grid .k {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #8892b0;
|
||||||
|
}
|
||||||
|
.options-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const root = document.getElementById("options-root");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
underlying: root.dataset.defaultUnderly || "ETH",
|
||||||
|
optType: "C",
|
||||||
|
chain: null,
|
||||||
|
selectedInst: null,
|
||||||
|
convertQuoteId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(v, d) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||||
|
return Number(v).toFixed(d == null ? 2 : d);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(url, opts) {
|
||||||
|
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBalances() {
|
||||||
|
const d = await apiJson("/api/options/balances");
|
||||||
|
if (!d.ok) return;
|
||||||
|
document.getElementById("opt-funding-usdt").textContent = fmt(d.funding_usdt) + " U";
|
||||||
|
document.getElementById("opt-funding-usdc").textContent = fmt(d.funding_usdc) + " U";
|
||||||
|
document.getElementById("opt-trading-usdt").textContent = fmt(d.trading_usdt) + " U";
|
||||||
|
document.getElementById("opt-trading-usdc").textContent = fmt(d.trading_usdc) + " U";
|
||||||
|
document.getElementById("opt-trading-usdg").textContent = fmt(d.trading_usdg) + " U";
|
||||||
|
document.getElementById("opt-trade-budget").textContent = fmt(d.trade_budget) + " USDC";
|
||||||
|
}
|
||||||
|
|
||||||
|
function expLabel(ms) {
|
||||||
|
try {
|
||||||
|
return new Date(Number(ms)).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch (e) {
|
||||||
|
return String(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpiries() {
|
||||||
|
const sel = document.getElementById("opt-exp-select");
|
||||||
|
sel.innerHTML = '<option value="">选择到期日</option>';
|
||||||
|
const exps = (state.chain && state.chain.expiries) || [];
|
||||||
|
exps.forEach(function (e) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = String(e.exp_time);
|
||||||
|
o.textContent = expLabel(e.exp_time) + " (" + e.contracts.length + ")";
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
document.getElementById("opt-index-line").textContent =
|
||||||
|
"指数 " + state.underlying + " ≈ " + fmt(state.chain && state.chain.index_px, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStrikes() {
|
||||||
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
|
const expMs = document.getElementById("opt-exp-select").value;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
if (!expMs || !state.chain) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="muted">请选择到期日</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exp = (state.chain.expiries || []).find(function (e) {
|
||||||
|
return String(e.exp_time) === String(expMs);
|
||||||
|
});
|
||||||
|
if (!exp) return;
|
||||||
|
const list = (exp.contracts || []).filter(function (c) {
|
||||||
|
return c.opt_type === state.optType;
|
||||||
|
});
|
||||||
|
if (!list.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="muted">无符合的实值合约</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (c) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML =
|
||||||
|
"<td>" + c.strike + "</td>" +
|
||||||
|
"<td><code>" + c.inst_id + "</code></td>" +
|
||||||
|
"<td>" + fmt(c.ask, 4) + "</td>" +
|
||||||
|
"<td>" + fmt(c.bid, 4) + "</td>" +
|
||||||
|
'<td><button type="button" class="btn-secondary opt-pick-btn" data-inst="' + c.inst_id + '">选择</button></td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
selectContract(btn.getAttribute("data-inst"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectContract(instId) {
|
||||||
|
state.selectedInst = instId;
|
||||||
|
const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
|
||||||
|
const ethInput = document.getElementById("opt-eth-amount");
|
||||||
|
let url = "/api/options/quote?inst_id=" + encodeURIComponent(instId) + "&mode=" + mode;
|
||||||
|
if (mode === "eth_amount" && ethInput.value) {
|
||||||
|
url += "ð_amount=" + encodeURIComponent(ethInput.value);
|
||||||
|
}
|
||||||
|
const d = await apiJson(url);
|
||||||
|
const panel = document.getElementById("opt-order-panel");
|
||||||
|
panel.style.display = "";
|
||||||
|
document.getElementById("opt-order-inst").textContent = instId;
|
||||||
|
document.getElementById("opt-order-ask").textContent = fmt(d.ask, 4);
|
||||||
|
const sz = d.sizing || {};
|
||||||
|
document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—";
|
||||||
|
document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—";
|
||||||
|
document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmt(sz.total_premium, 4) + " USDC" : "—";
|
||||||
|
document.getElementById("opt-order-msg").textContent = sz.ok === false ? (sz.msg || "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChain() {
|
||||||
|
const d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(state.underlying));
|
||||||
|
if (!d.ok) {
|
||||||
|
alert(d.msg || "加载失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.chain = d;
|
||||||
|
renderExpiries();
|
||||||
|
renderStrikes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPosition() {
|
||||||
|
if (!state.selectedInst) return;
|
||||||
|
const mode = document.querySelector('input[name="opt-size-mode"]:checked').value;
|
||||||
|
const body = {
|
||||||
|
inst_id: state.selectedInst,
|
||||||
|
mode: mode,
|
||||||
|
signal_note: document.getElementById("opt-signal-note").value || "",
|
||||||
|
};
|
||||||
|
if (mode === "eth_amount") {
|
||||||
|
body.eth_amount = parseFloat(document.getElementById("opt-eth-amount").value);
|
||||||
|
}
|
||||||
|
const d = await apiJson("/api/options/open", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
document.getElementById("opt-order-msg").textContent = d.ok ? "下单已提交" : (d.msg || "失败");
|
||||||
|
if (d.ok) {
|
||||||
|
refreshBalances();
|
||||||
|
refreshPositions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPositions() {
|
||||||
|
const d = await apiJson("/api/options/positions");
|
||||||
|
const tbody = document.getElementById("opt-positions-tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const list = (d.ok && d.positions) || [];
|
||||||
|
if (!list.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无持仓</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach(function (p) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML =
|
||||||
|
"<td><code>" + (p.inst_id || "") + "</code></td>" +
|
||||||
|
"<td>" + fmt(p.pos, 0) + "</td>" +
|
||||||
|
"<td>" + fmt(p.eth_amount, 4) + "</td>" +
|
||||||
|
"<td>" + fmt(p.avg_px, 4) + "</td>" +
|
||||||
|
"<td>" + fmt(p.mark_px, 4) + "</td>" +
|
||||||
|
"<td>" + fmt(p.upl, 4) + "</td>" +
|
||||||
|
"<td>" + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "</td>" +
|
||||||
|
'<td><button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '">限价平仓</button></td>';
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll(".opt-close-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", async function () {
|
||||||
|
const inst = btn.getAttribute("data-inst");
|
||||||
|
if (!confirm("确认限价卖出 @ 买一?")) return;
|
||||||
|
const r = await apiJson("/api/options/close", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ inst_id: inst }),
|
||||||
|
});
|
||||||
|
alert(r.ok ? "平仓单已提交" : (r.msg || "失败"));
|
||||||
|
refreshPositions();
|
||||||
|
refreshBalances();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".opt-uly-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
document.querySelectorAll(".opt-uly-btn").forEach(function (b) { b.classList.remove("active"); });
|
||||||
|
btn.classList.add("active");
|
||||||
|
state.underlying = btn.getAttribute("data-uly");
|
||||||
|
loadChain();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".opt-type-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); });
|
||||||
|
btn.classList.add("active");
|
||||||
|
state.optType = btn.getAttribute("data-type");
|
||||||
|
renderStrikes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("opt-exp-select").addEventListener("change", renderStrikes);
|
||||||
|
document.getElementById("opt-load-chain").addEventListener("click", loadChain);
|
||||||
|
document.getElementById("opt-refresh-balances").addEventListener("click", refreshBalances);
|
||||||
|
document.getElementById("opt-refresh-positions").addEventListener("click", refreshPositions);
|
||||||
|
document.getElementById("opt-open-btn").addEventListener("click", openPosition);
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
||||||
|
r.addEventListener("change", function () {
|
||||||
|
document.getElementById("opt-eth-amount").style.display =
|
||||||
|
r.value === "eth_amount" && r.checked ? "" : "none";
|
||||||
|
if (state.selectedInst) selectContract(state.selectedInst);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("opt-convert-quote-btn").addEventListener("click", async function () {
|
||||||
|
const amount = parseFloat(document.getElementById("opt-convert-amount").value);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
alert("请输入 USDT 数量");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = await apiJson("/api/options/convert/quote", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount: amount }),
|
||||||
|
});
|
||||||
|
const prev = document.getElementById("opt-convert-preview");
|
||||||
|
if (!d.ok) {
|
||||||
|
prev.textContent = d.msg || "询价失败";
|
||||||
|
state.convertQuoteId = null;
|
||||||
|
document.getElementById("opt-convert-exec-btn").disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.convertQuoteId = d.quote_id;
|
||||||
|
prev.textContent =
|
||||||
|
"预估获得 " + fmt(d.base_sz, 6) + " USDC,汇率 " + fmt(d.cnvt_px, 6);
|
||||||
|
document.getElementById("opt-convert-exec-btn").disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("opt-convert-exec-btn").addEventListener("click", async function () {
|
||||||
|
if (!state.convertQuoteId) return;
|
||||||
|
const amount = parseFloat(document.getElementById("opt-convert-amount").value);
|
||||||
|
const d = await apiJson("/api/options/convert/execute", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ quote_id: state.convertQuoteId, rfq_sz: amount }),
|
||||||
|
});
|
||||||
|
document.getElementById("opt-convert-preview").textContent = d.ok ? "兑换成功" : (d.msg || "失败");
|
||||||
|
state.convertQuoteId = null;
|
||||||
|
document.getElementById("opt-convert-exec-btn").disabled = true;
|
||||||
|
refreshBalances();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("opt-transfer-btn").addEventListener("click", async function () {
|
||||||
|
const d = await apiJson("/api/options/transfer", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ccy: document.getElementById("opt-transfer-ccy").value,
|
||||||
|
from: document.getElementById("opt-transfer-from").value,
|
||||||
|
to: document.getElementById("opt-transfer-to").value,
|
||||||
|
amount: parseFloat(document.getElementById("opt-transfer-amount").value),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById("opt-transfer-msg").textContent = d.ok ? "划转成功" : (d.msg || "失败");
|
||||||
|
refreshBalances();
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBalances();
|
||||||
|
loadChain();
|
||||||
|
refreshPositions();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""OKX USDⓈ 期权 API 封装(主账户 exchange_options 专用)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
|
||||||
|
from lib.options.options_pricing_lib import is_shallow_itm
|
||||||
|
|
||||||
|
|
||||||
|
def create_options_exchange(
|
||||||
|
api_key: str,
|
||||||
|
api_secret: str,
|
||||||
|
passphrase: str,
|
||||||
|
proxies: dict[str, str] | None = None,
|
||||||
|
) -> ccxt.okx:
|
||||||
|
ex = ccxt.okx(
|
||||||
|
{
|
||||||
|
"apiKey": api_key,
|
||||||
|
"secret": api_secret,
|
||||||
|
"password": passphrase,
|
||||||
|
"enableRateLimit": True,
|
||||||
|
"options": {"defaultType": "option"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if proxies:
|
||||||
|
ex.proxies = proxies
|
||||||
|
return ex
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v: Any) -> float | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ccy_balance(balance: dict[str, Any], ccy: str) -> float | None:
|
||||||
|
ccy = (ccy or "").upper()
|
||||||
|
if not isinstance(balance, dict):
|
||||||
|
return None
|
||||||
|
info = balance.get(ccy)
|
||||||
|
if isinstance(info, dict):
|
||||||
|
for k in ("free", "total", "eq"):
|
||||||
|
v = _safe_float(info.get(k))
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
total_map = balance.get("total") or {}
|
||||||
|
if isinstance(total_map, dict):
|
||||||
|
v = _safe_float(total_map.get(ccy))
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
free_map = balance.get("free") or {}
|
||||||
|
if isinstance(free_map, dict):
|
||||||
|
v = _safe_float(free_map.get(ccy))
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_account_balances_by_type(ex: ccxt.okx, account_type: str) -> dict[str, float | None]:
|
||||||
|
out: dict[str, float | None] = {"USDT": None, "USDC": None, "USDG": None}
|
||||||
|
try:
|
||||||
|
bal = ex.fetch_balance(params={"type": account_type})
|
||||||
|
for c in out:
|
||||||
|
out[c] = _extract_ccy_balance(bal, c)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_options_balances(ex: ccxt.okx) -> dict[str, Any]:
|
||||||
|
funding = fetch_account_balances_by_type(ex, "funding")
|
||||||
|
trading = fetch_account_balances_by_type(ex, "trading")
|
||||||
|
# 统一账户部分 USDC 可能在 swap 类型
|
||||||
|
if trading.get("USDC") is None:
|
||||||
|
swap_bal = fetch_account_balances_by_type(ex, "swap")
|
||||||
|
if swap_bal.get("USDC") is not None:
|
||||||
|
trading["USDC"] = swap_bal["USDC"]
|
||||||
|
return {
|
||||||
|
"funding_usdt": funding.get("USDT"),
|
||||||
|
"funding_usdc": funding.get("USDC"),
|
||||||
|
"funding_usdg": funding.get("USDG"),
|
||||||
|
"trading_usdt": trading.get("USDT"),
|
||||||
|
"trading_usdc": trading.get("USDC"),
|
||||||
|
"trading_usdg": trading.get("USDG"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
||||||
|
inst = f"{uly}" if "-" in uly else f"{uly}-USD"
|
||||||
|
try:
|
||||||
|
rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or []
|
||||||
|
if rows:
|
||||||
|
return _safe_float(rows[0].get("idxPx"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_option_instruments(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_family: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
rows = ex.public_get_public_instruments(
|
||||||
|
{"instType": "OPTION", "instFamily": inst_family}
|
||||||
|
).get("data") or []
|
||||||
|
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
rows = ex.public_get_market_tickers(
|
||||||
|
{"instType": "OPTION", "instFamily": inst_family}
|
||||||
|
).get("data") or []
|
||||||
|
for r in rows:
|
||||||
|
if isinstance(r, dict) and r.get("instId"):
|
||||||
|
out[str(r["instId"])] = r
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_option_chain(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
underlying: str,
|
||||||
|
*,
|
||||||
|
max_dte_days: float = 2.0,
|
||||||
|
itm_only: bool = True,
|
||||||
|
itm_max_dist_usd: float = 30.0,
|
||||||
|
index_px: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
u = (underlying or "ETH").upper()
|
||||||
|
family = f"{u}-USD_UM"
|
||||||
|
uly = f"{u}-USD"
|
||||||
|
idx = index_px if index_px is not None else fetch_index_price(ex, uly)
|
||||||
|
now_ms = time.time() * 1000
|
||||||
|
max_ms = now_ms + max_dte_days * 86400 * 1000
|
||||||
|
instruments = fetch_option_instruments(ex, family)
|
||||||
|
tickers = fetch_option_tickers(ex, family)
|
||||||
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for meta in instruments:
|
||||||
|
try:
|
||||||
|
exp_ms = int(meta.get("expTime") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if exp_ms <= now_ms or exp_ms > max_ms:
|
||||||
|
continue
|
||||||
|
opt_type = str(meta.get("optType") or "")
|
||||||
|
strike = _safe_float(meta.get("stk"))
|
||||||
|
if strike is None or idx is None:
|
||||||
|
continue
|
||||||
|
if itm_only and not is_shallow_itm(
|
||||||
|
opt_type=opt_type,
|
||||||
|
strike=strike,
|
||||||
|
index_px=idx,
|
||||||
|
max_dist_usd=itm_max_dist_usd,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
inst_id = str(meta.get("instId") or "")
|
||||||
|
t = tickers.get(inst_id) or {}
|
||||||
|
ask = _safe_float(t.get("askPx"))
|
||||||
|
bid = _safe_float(t.get("bidPx"))
|
||||||
|
if ask is None and bid is None:
|
||||||
|
continue
|
||||||
|
exp_key = str(exp_ms)
|
||||||
|
expiries.setdefault(exp_key, []).append(
|
||||||
|
{
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"strike": strike,
|
||||||
|
"opt_type": opt_type,
|
||||||
|
"exp_time": exp_ms,
|
||||||
|
"ask": ask,
|
||||||
|
"bid": bid,
|
||||||
|
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
|
||||||
|
"tick_sz": meta.get("tickSz"),
|
||||||
|
"min_sz": int(_safe_float(meta.get("minSz")) or 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exp_list = []
|
||||||
|
for exp_ms_str, contracts in sorted(expiries.items(), key=lambda x: int(x[0])):
|
||||||
|
contracts.sort(key=lambda c: (c["opt_type"], c["strike"]))
|
||||||
|
exp_list.append({"exp_time": int(exp_ms_str), "contracts": contracts})
|
||||||
|
return {"underlying": u, "index_px": idx, "inst_family": family, "expiries": exp_list}
|
||||||
|
|
||||||
|
|
||||||
|
def quote_option_contract(ex: ccxt.okx, inst_id: str) -> dict[str, Any]:
|
||||||
|
meta_rows = ex.public_get_public_instruments(
|
||||||
|
{"instType": "OPTION", "instId": inst_id}
|
||||||
|
).get("data") or []
|
||||||
|
if not meta_rows:
|
||||||
|
return {"ok": False, "msg": "合约不存在"}
|
||||||
|
meta = meta_rows[0]
|
||||||
|
t_rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or []
|
||||||
|
t = t_rows[0] if t_rows else {}
|
||||||
|
uly = str(meta.get("uly") or "")
|
||||||
|
idx = fetch_index_price(ex, uly)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"meta": meta,
|
||||||
|
"ask": _safe_float(t.get("askPx")),
|
||||||
|
"bid": _safe_float(t.get("bidPx")),
|
||||||
|
"mark": _safe_float(t.get("markPx")),
|
||||||
|
"index_px": idx,
|
||||||
|
"ct_mult": _safe_float(meta.get("ctMult")) or 0.01,
|
||||||
|
"min_sz": int(_safe_float(meta.get("minSz")) or 1),
|
||||||
|
"tick_sz": meta.get("tickSz"),
|
||||||
|
"strike": _safe_float(meta.get("stk")),
|
||||||
|
"opt_type": meta.get("optType"),
|
||||||
|
"exp_time": meta.get("expTime"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def place_option_limit_order(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
*,
|
||||||
|
inst_id: str,
|
||||||
|
side: str,
|
||||||
|
sheets: int,
|
||||||
|
price: float,
|
||||||
|
td_mode: str = "cross",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
side_l = (side or "").lower()
|
||||||
|
if side_l not in ("buy", "sell"):
|
||||||
|
return {"ok": False, "msg": "side 必须为 buy 或 sell"}
|
||||||
|
if sheets < 1:
|
||||||
|
return {"ok": False, "msg": "张数至少为 1"}
|
||||||
|
try:
|
||||||
|
resp = ex.private_post_trade_order(
|
||||||
|
{
|
||||||
|
"instId": inst_id,
|
||||||
|
"tdMode": td_mode,
|
||||||
|
"side": side_l,
|
||||||
|
"ordType": "limit",
|
||||||
|
"px": str(price),
|
||||||
|
"sz": str(int(sheets)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = (resp or {}).get("data") or []
|
||||||
|
if data and str(data[0].get("sCode")) == "0":
|
||||||
|
return {"ok": True, "data": data[0], "raw": resp}
|
||||||
|
msg = data[0].get("sMsg") if data else str(resp)
|
||||||
|
return {"ok": False, "msg": msg or "下单失败", "raw": resp}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
rows = ex.private_get_account_positions({"instType": "OPTION"}).get("data") or []
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
pos = _safe_float(r.get("pos"))
|
||||||
|
if pos is None or abs(pos) < 1e-12:
|
||||||
|
continue
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_usdt_to_usdc(ex: ccxt.okx, usdt_amount: float) -> dict[str, Any]:
|
||||||
|
if usdt_amount <= 0:
|
||||||
|
return {"ok": False, "msg": "兑换数量须大于 0"}
|
||||||
|
try:
|
||||||
|
resp = ex.private_post_asset_convert_estimate_quote(
|
||||||
|
{
|
||||||
|
"baseCcy": "USDC",
|
||||||
|
"quoteCcy": "USDT",
|
||||||
|
"side": "buy",
|
||||||
|
"rfqSz": str(usdt_amount),
|
||||||
|
"rfqSzCcy": "USDT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data = (resp or {}).get("data") or []
|
||||||
|
if not data:
|
||||||
|
return {"ok": False, "msg": "询价失败", "raw": resp}
|
||||||
|
row = data[0]
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"quote_id": row.get("quoteId"),
|
||||||
|
"base_ccy": row.get("baseCcy"),
|
||||||
|
"quote_ccy": row.get("quoteCcy"),
|
||||||
|
"cnvt_px": _safe_float(row.get("cnvtPx")),
|
||||||
|
"base_sz": _safe_float(row.get("baseSz")),
|
||||||
|
"quote_sz": _safe_float(row.get("quoteSz")),
|
||||||
|
"rfq_sz": usdt_amount,
|
||||||
|
"raw": row,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_convert(ex: ccxt.okx, quote_id: str) -> dict[str, Any]:
|
||||||
|
if not quote_id:
|
||||||
|
return {"ok": False, "msg": "缺少 quoteId"}
|
||||||
|
try:
|
||||||
|
resp = ex.private_post_asset_convert_trade({"quoteId": str(quote_id)})
|
||||||
|
data = (resp or {}).get("data") or []
|
||||||
|
if data and str(data[0].get("sCode", "0")) == "0":
|
||||||
|
return {"ok": True, "data": data[0], "raw": resp}
|
||||||
|
msg = data[0].get("sMsg") if data else str(resp)
|
||||||
|
return {"ok": False, "msg": msg or "兑换失败", "raw": resp}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_ccy(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
ccy: str,
|
||||||
|
amount: float,
|
||||||
|
from_account: str,
|
||||||
|
to_account: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if amount <= 0:
|
||||||
|
return {"ok": False, "msg": "划转金额须大于 0"}
|
||||||
|
try:
|
||||||
|
resp = ex.transfer(str(ccy).upper(), float(amount), from_account, to_account)
|
||||||
|
return {"ok": True, "data": resp}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]:
|
||||||
|
sheets = _safe_float(pos.get("pos")) or 0.0
|
||||||
|
avg = _safe_float(pos.get("avgPx"))
|
||||||
|
mark = _safe_float(pos.get("markPx"))
|
||||||
|
upl = _safe_float(pos.get("upl"))
|
||||||
|
upl_ratio = _safe_float(pos.get("uplRatio"))
|
||||||
|
return {
|
||||||
|
"inst_id": pos.get("instId"),
|
||||||
|
"pos": sheets,
|
||||||
|
"eth_amount": round(abs(sheets) * ct_mult, 8),
|
||||||
|
"avg_px": avg,
|
||||||
|
"mark_px": mark,
|
||||||
|
"upl": upl,
|
||||||
|
"upl_ratio_pct": round(upl_ratio * 100, 2) if upl_ratio is not None else None,
|
||||||
|
"exp_time": pos.get("expTime"),
|
||||||
|
"opt_type": pos.get("optType"),
|
||||||
|
"strike": _safe_float(pos.get("stk")),
|
||||||
|
"avail_pos": _safe_float(pos.get("availPos")),
|
||||||
|
"raw": pos,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def options_api_ready(ex: ccxt.okx | None) -> tuple[bool, str]:
|
||||||
|
if ex is None:
|
||||||
|
return False, "期权 API 未配置"
|
||||||
|
if not ex.apiKey or not ex.secret or not ex.password:
|
||||||
|
return False, "期权 API Key 不完整"
|
||||||
|
return True, ""
|
||||||
@@ -15,6 +15,7 @@ EMBED_TABS: tuple[str, ...] = (
|
|||||||
"trade",
|
"trade",
|
||||||
"strategy",
|
"strategy",
|
||||||
"strategy_records",
|
"strategy_records",
|
||||||
|
"options",
|
||||||
"records",
|
"records",
|
||||||
"stats",
|
"stats",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -28,6 +29,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
|||||||
"/strategy/trend": "strategy",
|
"/strategy/trend": "strategy",
|
||||||
"/strategy/roll": "strategy",
|
"/strategy/roll": "strategy",
|
||||||
"/strategy/records": "strategy_records",
|
"/strategy/records": "strategy_records",
|
||||||
|
"/options": "options",
|
||||||
"/records": "records",
|
"/records": "records",
|
||||||
"/stats": "stats",
|
"/stats": "stats",
|
||||||
"/settings": "settings",
|
"/settings": "settings",
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ def build_instance_settings_view(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (exchange_key or "").strip().lower() == "okx" and _env_bool("OKX_OPTIONS_ENABLED", False):
|
||||||
|
opt_key = (os.getenv("OKX_OPTIONS_API_KEY") or "").strip()
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"title": "期权账户(主账户)",
|
||||||
|
"rows": [
|
||||||
|
_row("期权模块", "已启用"),
|
||||||
|
_row(
|
||||||
|
"期权 API",
|
||||||
|
f"已配置(…{opt_key[-4:]})" if len(opt_key) >= 4 else "未配置",
|
||||||
|
),
|
||||||
|
_row("单笔权利金上限", f"{_env_float('OKX_OPTIONS_TRADE_BUDGET_USDC', 10):g} USDC"),
|
||||||
|
_row(
|
||||||
|
"资金说明",
|
||||||
|
"资金账户 USDT 兑换 USDC 后划转到交易账户",
|
||||||
|
"期权页操作;与永续子账户资金分开",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
policy_note = ""
|
policy_note = ""
|
||||||
if trade_policy and getattr(trade_policy, "badge_text", ""):
|
if trade_policy and getattr(trade_policy, "badge_text", ""):
|
||||||
policy_note = str(trade_policy.badge_text)
|
policy_note = str(trade_policy.badge_text)
|
||||||
|
|||||||
@@ -206,6 +206,8 @@
|
|||||||
{% include 'strategy_trading_page.html' %}
|
{% include 'strategy_trading_page.html' %}
|
||||||
{% elif page == 'strategy_records' %}
|
{% elif page == 'strategy_records' %}
|
||||||
{% include 'strategy_records_page.html' %}
|
{% include 'strategy_records_page.html' %}
|
||||||
|
{% elif page == 'options' %}
|
||||||
|
{% include 'options_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
|
{% if options_enabled %}
|
||||||
|
<a href="/options" data-embed-tab="options" class="{% if initial_tab == 'options' %}active{% endif %}">期权</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=1">
|
<link rel="stylesheet" href="/static/instance_page.css?v=1">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=60">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=61">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -61,14 +61,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
|
{% if options_enabled %}
|
||||||
|
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
</div>
|
</div>
|
||||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||||
|
|
||||||
{% if page != 'settings' %}
|
{% if page != 'settings' and page != 'options' %}
|
||||||
{% include 'instance_header_panel.html' %}
|
{% include 'instance_header_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page != 'settings' %}
|
{% if page != 'settings' and page != 'options' %}
|
||||||
{% include 'instance_top_bar.html' %}
|
{% include 'instance_top_bar.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -275,6 +278,8 @@
|
|||||||
{% include 'strategy_trading_page.html' %}
|
{% include 'strategy_trading_page.html' %}
|
||||||
{% elif page == 'strategy_records' %}
|
{% elif page == 'strategy_records' %}
|
||||||
{% include 'strategy_records_page.html' %}
|
{% include 'strategy_records_page.html' %}
|
||||||
|
{% elif page == 'options' %}
|
||||||
|
{% include 'options_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""期权模块 SQLite 表。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def init_options_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS options_trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
inst_id TEXT NOT NULL,
|
||||||
|
underlying TEXT NOT NULL,
|
||||||
|
opt_type TEXT NOT NULL,
|
||||||
|
strike REAL,
|
||||||
|
exp_time TEXT,
|
||||||
|
sheets INTEGER NOT NULL,
|
||||||
|
eth_amount REAL NOT NULL,
|
||||||
|
open_quote REAL,
|
||||||
|
premium_paid REAL,
|
||||||
|
status TEXT DEFAULT 'open',
|
||||||
|
close_quote REAL,
|
||||||
|
premium_received REAL,
|
||||||
|
realized_pnl REAL,
|
||||||
|
profit_alert_sent INTEGER DEFAULT 0,
|
||||||
|
signal_note TEXT,
|
||||||
|
exchange_ord_id TEXT,
|
||||||
|
close_ord_id TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
closed_at TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS options_convert_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_ccy TEXT,
|
||||||
|
to_ccy TEXT,
|
||||||
|
rfq_sz REAL,
|
||||||
|
received_sz REAL,
|
||||||
|
quote_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS options_transfer_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ccy TEXT,
|
||||||
|
amount REAL,
|
||||||
|
from_account TEXT,
|
||||||
|
to_account TEXT,
|
||||||
|
status TEXT,
|
||||||
|
message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""期权持仓监控:浮盈翻倍微信提醒。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v: Any) -> float | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_profit_alert_message(
|
||||||
|
*,
|
||||||
|
account_label: str,
|
||||||
|
inst_id: str,
|
||||||
|
premium_paid: float,
|
||||||
|
upl: float,
|
||||||
|
upl_ratio: float | None,
|
||||||
|
bid: float | None,
|
||||||
|
) -> str:
|
||||||
|
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—"
|
||||||
|
bid_txt = f"{bid:.4f}" if bid is not None else "—"
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"【OKX期权·翻倍提醒】",
|
||||||
|
f"账户:{account_label}",
|
||||||
|
f"合约:{inst_id}",
|
||||||
|
f"已付权利金:{premium_paid:.4f} USDC",
|
||||||
|
f"未实现盈亏:{upl:+.4f} USDC({pct})",
|
||||||
|
f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_options_profit_alerts(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
positions: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
profit_ratio: float,
|
||||||
|
send_wechat: Callable[[str], None],
|
||||||
|
account_label: str,
|
||||||
|
ticker_bid_fn: Callable[[str], float | None],
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
对比 DB 中 open 记录与交易所持仓;达到阈值发微信。
|
||||||
|
返回发送条数。
|
||||||
|
"""
|
||||||
|
sent = 0
|
||||||
|
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, inst_id, premium_paid, profit_alert_sent
|
||||||
|
FROM options_trades
|
||||||
|
WHERE status = 'open'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
if int(row["profit_alert_sent"] or 0):
|
||||||
|
continue
|
||||||
|
inst_id = str(row["inst_id"] or "")
|
||||||
|
prem = _safe_float(row["premium_paid"])
|
||||||
|
if not inst_id or prem is None or prem <= 0:
|
||||||
|
continue
|
||||||
|
pos = pos_by_inst.get(inst_id)
|
||||||
|
if not pos:
|
||||||
|
continue
|
||||||
|
upl = _safe_float(pos.get("upl"))
|
||||||
|
upl_ratio = _safe_float(pos.get("upl_ratio_pct"))
|
||||||
|
if upl_ratio is not None:
|
||||||
|
ratio = upl_ratio / 100.0
|
||||||
|
elif upl is not None:
|
||||||
|
ratio = upl / prem
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if ratio < float(profit_ratio):
|
||||||
|
continue
|
||||||
|
bid = ticker_bid_fn(inst_id)
|
||||||
|
msg = build_profit_alert_message(
|
||||||
|
account_label=account_label,
|
||||||
|
inst_id=inst_id,
|
||||||
|
premium_paid=prem,
|
||||||
|
upl=upl or 0.0,
|
||||||
|
upl_ratio=ratio,
|
||||||
|
bid=bid,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_wechat(msg)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE options_trades SET profit_alert_sent = 1 WHERE id = ?",
|
||||||
|
(int(row["id"]),),
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def options_monitor_loop(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
poll_seconds: float,
|
||||||
|
get_db: Callable[[], sqlite3.Connection],
|
||||||
|
fetch_positions: Callable[[], list[dict[str, Any]]],
|
||||||
|
ticker_bid_fn: Callable[[str], float | None],
|
||||||
|
send_wechat: Callable[[str], None],
|
||||||
|
account_label: str,
|
||||||
|
profit_ratio: float,
|
||||||
|
stop_event: Any = None,
|
||||||
|
) -> None:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
positions = fetch_positions()
|
||||||
|
run_options_profit_alerts(
|
||||||
|
conn,
|
||||||
|
positions,
|
||||||
|
profit_ratio=profit_ratio,
|
||||||
|
send_wechat=send_wechat,
|
||||||
|
account_label=account_label,
|
||||||
|
ticker_bid_fn=ticker_bid_fn,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(max(5.0, float(poll_seconds)))
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""OKX USDⓈ 期权:张数与权利金计算。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
|
||||||
|
if not meta:
|
||||||
|
return 0.01
|
||||||
|
try:
|
||||||
|
return float(meta.get("ctMult") or 0.01)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def min_sz_from_meta(meta: dict[str, Any] | None) -> int:
|
||||||
|
if not meta:
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
return max(1, int(float(meta.get("minSz") or 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
|
||||||
|
"""报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult。"""
|
||||||
|
return float(quote_per_unit) * float(ct_mult)
|
||||||
|
|
||||||
|
|
||||||
|
def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float:
|
||||||
|
return float(quote_per_unit) * float(eth_amount)
|
||||||
|
|
||||||
|
|
||||||
|
def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int:
|
||||||
|
if eth_amount <= 0 or ct_mult <= 0:
|
||||||
|
return 0
|
||||||
|
return int(math.floor(eth_amount / ct_mult + 1e-12))
|
||||||
|
|
||||||
|
|
||||||
|
def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float:
|
||||||
|
return round(int(sheets) * float(ct_mult), 8)
|
||||||
|
|
||||||
|
|
||||||
|
def calc_order_size(
|
||||||
|
*,
|
||||||
|
quote_per_unit: float,
|
||||||
|
ct_mult: float,
|
||||||
|
min_sz: int,
|
||||||
|
budget_usdc: float | None = None,
|
||||||
|
budget_buffer: float = 0.95,
|
||||||
|
eth_amount: float | None = None,
|
||||||
|
budget_cap: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
返回 sheets, eth_amount, total_premium。
|
||||||
|
mode: budget_full 或 eth_amount。
|
||||||
|
"""
|
||||||
|
if quote_per_unit <= 0:
|
||||||
|
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||||
|
|
||||||
|
if eth_amount is not None and eth_amount > 0:
|
||||||
|
sheets = sheets_from_eth_amount(eth_amount, ct_mult)
|
||||||
|
elif budget_usdc is not None and budget_usdc > 0:
|
||||||
|
eff = float(budget_usdc) * float(budget_buffer)
|
||||||
|
per_sheet = premium_per_sheet(quote_per_unit, ct_mult)
|
||||||
|
if per_sheet <= 0:
|
||||||
|
return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||||
|
sheets = int(math.floor(eff / per_sheet))
|
||||||
|
else:
|
||||||
|
return {"ok": False, "msg": "请指定预算或 ETH 数量", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
|
||||||
|
|
||||||
|
if sheets < min_sz:
|
||||||
|
per = premium_per_sheet(quote_per_unit, ct_mult)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)",
|
||||||
|
"sheets": sheets,
|
||||||
|
"eth_amount": eth_amount_from_sheets(sheets, ct_mult),
|
||||||
|
"total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)),
|
||||||
|
}
|
||||||
|
|
||||||
|
eth = eth_amount_from_sheets(sheets, ct_mult)
|
||||||
|
prem = total_premium(quote_per_unit, eth)
|
||||||
|
if budget_cap is not None and prem > float(budget_cap) + 1e-9:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC",
|
||||||
|
"sheets": sheets,
|
||||||
|
"eth_amount": eth,
|
||||||
|
"total_premium": prem,
|
||||||
|
}
|
||||||
|
return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem}
|
||||||
|
|
||||||
|
|
||||||
|
def is_shallow_itm(
|
||||||
|
*,
|
||||||
|
opt_type: str,
|
||||||
|
strike: float,
|
||||||
|
index_px: float,
|
||||||
|
max_dist_usd: float,
|
||||||
|
) -> bool:
|
||||||
|
o = (opt_type or "").upper()
|
||||||
|
if o == "C":
|
||||||
|
if strike >= index_px:
|
||||||
|
return False
|
||||||
|
return (index_px - strike) <= max_dist_usd
|
||||||
|
if o == "P":
|
||||||
|
if strike <= index_px:
|
||||||
|
return False
|
||||||
|
return (strike - index_px) <= max_dist_usd
|
||||||
|
return False
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
"""OKX 期权模块:Flask 路由注册。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, redirect, request, url_for
|
||||||
|
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||||
|
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
from lib.options.options_monitor_lib import options_monitor_loop
|
||||||
|
from lib.options.options_pricing_lib import (
|
||||||
|
calc_order_size,
|
||||||
|
ct_mult_from_meta,
|
||||||
|
min_sz_from_meta,
|
||||||
|
premium_per_sheet,
|
||||||
|
total_premium,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(key: str, default: bool = False) -> bool:
|
||||||
|
raw = (os.getenv(key) or "").strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return default
|
||||||
|
return raw in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(key: str, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(os.getenv(key, str(default)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def attach_options_templates(app: Flask, repo_root: str) -> None:
|
||||||
|
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
|
||||||
|
if not os.path.isdir(tpl_dir):
|
||||||
|
return
|
||||||
|
existing = app.jinja_loader
|
||||||
|
loaders = [FileSystemLoader(tpl_dir)]
|
||||||
|
if existing is not None:
|
||||||
|
if isinstance(existing, ChoiceLoader):
|
||||||
|
loaders = list(existing.loaders) + loaders
|
||||||
|
else:
|
||||||
|
loaders.insert(0, existing)
|
||||||
|
app.jinja_loader = ChoiceLoader(loaders)
|
||||||
|
|
||||||
|
|
||||||
|
def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None:
|
||||||
|
enabled = _env_bool("OKX_OPTIONS_ENABLED", False)
|
||||||
|
attach_options_templates(app, repo_root)
|
||||||
|
cfg = _build_cfg(app_module)
|
||||||
|
app.extensions["options_cfg"] = cfg
|
||||||
|
if enabled:
|
||||||
|
register_options_routes(app, cfg)
|
||||||
|
_start_monitor_thread(app, cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cfg(app_module: Any) -> dict[str, Any]:
|
||||||
|
from lib.exchange.okx_options_lib import (
|
||||||
|
build_option_chain,
|
||||||
|
estimate_usdt_to_usdc,
|
||||||
|
execute_convert,
|
||||||
|
fetch_option_positions,
|
||||||
|
fetch_options_balances,
|
||||||
|
format_position_row,
|
||||||
|
options_api_ready,
|
||||||
|
place_option_limit_order,
|
||||||
|
quote_option_contract,
|
||||||
|
transfer_ccy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": _env_bool("OKX_OPTIONS_ENABLED", False),
|
||||||
|
"get_db": app_module.get_db,
|
||||||
|
"login_required": app_module.login_required,
|
||||||
|
"exchange_options": getattr(app_module, "exchange_options", None),
|
||||||
|
"send_wechat": app_module.send_wechat_msg,
|
||||||
|
"render_main_page": app_module.render_main_page,
|
||||||
|
"trade_budget": _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10.0),
|
||||||
|
"budget_buffer": _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95),
|
||||||
|
"default_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper(),
|
||||||
|
"max_dte_days": _env_float("OKX_OPTIONS_MAX_DTE_DAYS", 2.0),
|
||||||
|
"itm_max_dist": _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0),
|
||||||
|
"td_mode": (os.getenv("OKX_OPTIONS_TD_MODE") or "cross").strip(),
|
||||||
|
"allow_market_close": _env_bool("OKX_OPTIONS_ALLOW_MARKET_CLOSE", False),
|
||||||
|
"profit_ratio": _env_float("OKX_OPTIONS_PROFIT_ALERT_RATIO", 1.0),
|
||||||
|
"poll_seconds": _env_float("OKX_OPTIONS_POLL_SECONDS", 15.0),
|
||||||
|
"account_label": (os.getenv("OKX_OPTIONS_ACCOUNT_LABEL") or "OKX期权").strip(),
|
||||||
|
"build_option_chain": build_option_chain,
|
||||||
|
"quote_option_contract": quote_option_contract,
|
||||||
|
"place_option_limit_order": place_option_limit_order,
|
||||||
|
"fetch_option_positions": fetch_option_positions,
|
||||||
|
"fetch_options_balances": fetch_options_balances,
|
||||||
|
"format_position_row": format_position_row,
|
||||||
|
"estimate_usdt_to_usdc": estimate_usdt_to_usdc,
|
||||||
|
"execute_convert": execute_convert,
|
||||||
|
"transfer_ccy": transfer_ccy,
|
||||||
|
"options_api_ready": options_api_ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_options_ex(cfg: dict[str, Any]):
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
ok, reason = cfg["options_api_ready"](ex)
|
||||||
|
if not ok:
|
||||||
|
return None, reason
|
||||||
|
return ex, ""
|
||||||
|
|
||||||
|
|
||||||
|
def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||||
|
lr = cfg["login_required"]
|
||||||
|
|
||||||
|
@app.route("/api/options/balances")
|
||||||
|
@lr
|
||||||
|
def api_options_balances():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
bal = cfg["fetch_options_balances"](ex)
|
||||||
|
return jsonify({"ok": True, **bal, "trade_budget": cfg["trade_budget"]})
|
||||||
|
|
||||||
|
@app.route("/api/options/chain")
|
||||||
|
@lr
|
||||||
|
def api_options_chain():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
||||||
|
chain = cfg["build_option_chain"](
|
||||||
|
ex,
|
||||||
|
u,
|
||||||
|
max_dte_days=cfg["max_dte_days"],
|
||||||
|
itm_only=True,
|
||||||
|
itm_max_dist_usd=cfg["itm_max_dist"],
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True, **chain})
|
||||||
|
|
||||||
|
@app.route("/api/options/quote")
|
||||||
|
@lr
|
||||||
|
def api_options_quote():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
inst_id = (request.args.get("inst_id") or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
|
if not q.get("ok"):
|
||||||
|
return jsonify(q)
|
||||||
|
ask = q.get("ask")
|
||||||
|
ct_mult = q.get("ct_mult") or 0.01
|
||||||
|
min_sz = q.get("min_sz") or 1
|
||||||
|
mode = (request.args.get("mode") or "budget_full").strip()
|
||||||
|
budget = cfg["trade_budget"]
|
||||||
|
eth_amount = None
|
||||||
|
try:
|
||||||
|
if request.args.get("eth_amount"):
|
||||||
|
eth_amount = float(request.args.get("eth_amount"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if ask is None or ask <= 0:
|
||||||
|
return jsonify({**q, "ok": False, "msg": "暂无卖一价"})
|
||||||
|
sizing = calc_order_size(
|
||||||
|
quote_per_unit=float(ask),
|
||||||
|
ct_mult=float(ct_mult),
|
||||||
|
min_sz=int(min_sz),
|
||||||
|
budget_usdc=budget if mode != "eth_amount" else None,
|
||||||
|
budget_buffer=cfg["budget_buffer"],
|
||||||
|
eth_amount=eth_amount if mode == "eth_amount" else None,
|
||||||
|
budget_cap=cfg["trade_budget"],
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
**q,
|
||||||
|
"quote_per_unit": ask,
|
||||||
|
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
||||||
|
"sizing": sizing,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/options/open", methods=["POST"])
|
||||||
|
@lr
|
||||||
|
def api_options_open():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
inst_id = (data.get("inst_id") or "").strip()
|
||||||
|
mode = (data.get("mode") or "budget_full").strip()
|
||||||
|
signal_note = (data.get("signal_note") or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
|
if not q.get("ok"):
|
||||||
|
return jsonify(q)
|
||||||
|
ask = q.get("ask")
|
||||||
|
if ask is None or ask <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "暂无卖一价,无法买入"})
|
||||||
|
ct_mult = float(q.get("ct_mult") or 0.01)
|
||||||
|
min_sz = int(q.get("min_sz") or 1)
|
||||||
|
eth_amount = None
|
||||||
|
if mode == "eth_amount":
|
||||||
|
try:
|
||||||
|
eth_amount = float(data.get("eth_amount"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "ETH 数量无效"})
|
||||||
|
sizing = calc_order_size(
|
||||||
|
quote_per_unit=float(ask),
|
||||||
|
ct_mult=ct_mult,
|
||||||
|
min_sz=min_sz,
|
||||||
|
budget_usdc=cfg["trade_budget"] if mode != "eth_amount" else None,
|
||||||
|
budget_buffer=cfg["budget_buffer"],
|
||||||
|
eth_amount=eth_amount,
|
||||||
|
budget_cap=cfg["trade_budget"],
|
||||||
|
)
|
||||||
|
if not sizing.get("ok"):
|
||||||
|
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||||
|
sheets = int(sizing["sheets"])
|
||||||
|
order = cfg["place_option_limit_order"](
|
||||||
|
ex,
|
||||||
|
inst_id=inst_id,
|
||||||
|
side="buy",
|
||||||
|
sheets=sheets,
|
||||||
|
price=float(ask),
|
||||||
|
td_mode=cfg["td_mode"],
|
||||||
|
)
|
||||||
|
if not order.get("ok"):
|
||||||
|
return jsonify(order)
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
meta = q.get("meta") or {}
|
||||||
|
u = str(meta.get("uly") or inst_id).split("-")[0]
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades
|
||||||
|
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||||
|
open_quote, premium_paid, status, signal_note, exchange_ord_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
inst_id,
|
||||||
|
u,
|
||||||
|
meta.get("optType"),
|
||||||
|
q.get("strike"),
|
||||||
|
str(q.get("exp_time") or ""),
|
||||||
|
sheets,
|
||||||
|
sizing["eth_amount"],
|
||||||
|
float(ask),
|
||||||
|
sizing["total_premium"],
|
||||||
|
signal_note,
|
||||||
|
(order.get("data") or {}).get("ordId"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": True, "order": order, "sizing": sizing})
|
||||||
|
|
||||||
|
@app.route("/api/options/positions")
|
||||||
|
@lr
|
||||||
|
def api_options_positions():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
|
rows = [cfg["format_position_row"](p) for p in raw]
|
||||||
|
return jsonify({"ok": True, "positions": rows})
|
||||||
|
|
||||||
|
@app.route("/api/options/close", methods=["POST"])
|
||||||
|
@lr
|
||||||
|
def api_options_close():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
inst_id = (data.get("inst_id") or "").strip()
|
||||||
|
use_market = bool(data.get("market")) and cfg["allow_market_close"]
|
||||||
|
if not inst_id:
|
||||||
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
|
sheets = data.get("sheets")
|
||||||
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
|
bid = q.get("bid")
|
||||||
|
if not use_market and (bid is None or bid <= 0):
|
||||||
|
return jsonify({"ok": False, "msg": "暂无买一价,无法限价平仓"})
|
||||||
|
raw_positions = cfg["fetch_option_positions"](ex)
|
||||||
|
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
|
||||||
|
if not pos:
|
||||||
|
return jsonify({"ok": False, "msg": "未找到持仓"})
|
||||||
|
avail = float(pos.get("availPos") or pos.get("pos") or 0)
|
||||||
|
close_sheets = int(sheets) if sheets else int(abs(avail))
|
||||||
|
if close_sheets < 1:
|
||||||
|
return jsonify({"ok": False, "msg": "可平张数不足"})
|
||||||
|
if use_market:
|
||||||
|
try:
|
||||||
|
resp = ex.private_post_trade_order(
|
||||||
|
{
|
||||||
|
"instId": inst_id,
|
||||||
|
"tdMode": cfg["td_mode"],
|
||||||
|
"side": "sell",
|
||||||
|
"ordType": "market",
|
||||||
|
"sz": str(close_sheets),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data_rows = (resp or {}).get("data") or []
|
||||||
|
if not data_rows or str(data_rows[0].get("sCode")) != "0":
|
||||||
|
return jsonify({"ok": False, "msg": data_rows[0].get("sMsg") if data_rows else "市价平仓失败"})
|
||||||
|
order = {"ok": True, "data": data_rows[0]}
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)})
|
||||||
|
else:
|
||||||
|
order = cfg["place_option_limit_order"](
|
||||||
|
ex,
|
||||||
|
inst_id=inst_id,
|
||||||
|
side="sell",
|
||||||
|
sheets=close_sheets,
|
||||||
|
price=float(bid),
|
||||||
|
td_mode=cfg["td_mode"],
|
||||||
|
)
|
||||||
|
if not order.get("ok"):
|
||||||
|
return jsonify(order)
|
||||||
|
prem_recv = total_premium(float(bid or 0), close_sheets * float(q.get("ct_mult") or 0.01))
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, premium_paid FROM options_trades WHERE inst_id = ? AND status = 'open' ORDER BY id DESC LIMIT 1",
|
||||||
|
(inst_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
paid = float(row["premium_paid"] or 0)
|
||||||
|
pnl = prem_recv - paid
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||||
|
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
bid,
|
||||||
|
prem_recv,
|
||||||
|
pnl,
|
||||||
|
(order.get("data") or {}).get("ordId"),
|
||||||
|
int(row["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"ok": True, "order": order, "bid": bid, "sheets": close_sheets})
|
||||||
|
|
||||||
|
@app.route("/api/options/convert/quote", methods=["POST"])
|
||||||
|
@lr
|
||||||
|
def api_options_convert_quote():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
amount = float(data.get("amount"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "数量无效"})
|
||||||
|
return jsonify(cfg["estimate_usdt_to_usdc"](ex, amount))
|
||||||
|
|
||||||
|
@app.route("/api/options/convert/execute", methods=["POST"])
|
||||||
|
@lr
|
||||||
|
def api_options_convert_execute():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
quote_id = (data.get("quote_id") or "").strip()
|
||||||
|
result = cfg["execute_convert"](ex, quote_id)
|
||||||
|
if result.get("ok"):
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_convert_log (from_ccy, to_ccy, rfq_sz, received_sz, quote_id, status, message)
|
||||||
|
VALUES ('USDT', 'USDC', ?, ?, ?, 'ok', '')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data.get("rfq_sz"),
|
||||||
|
(result.get("data") or {}).get("baseSz"),
|
||||||
|
quote_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
@app.route("/api/options/transfer", methods=["POST"])
|
||||||
|
@lr
|
||||||
|
def api_options_transfer():
|
||||||
|
ex, err = _require_options_ex(cfg)
|
||||||
|
if ex is None:
|
||||||
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
ccy = (data.get("ccy") or "USDC").upper()
|
||||||
|
from_acct = (data.get("from") or "funding").strip()
|
||||||
|
to_acct = (data.get("to") or "trading").strip()
|
||||||
|
try:
|
||||||
|
amount = float(data.get("amount"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "数量无效"})
|
||||||
|
result = cfg["transfer_ccy"](ex, ccy, amount, from_acct, to_acct)
|
||||||
|
if result.get("ok"):
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_transfer_log (ccy, amount, from_account, to_account, status, message)
|
||||||
|
VALUES (?, ?, ?, ?, 'ok', '')
|
||||||
|
""",
|
||||||
|
(ccy, amount, from_acct, to_acct),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||||
|
if app.extensions.get("options_monitor_started"):
|
||||||
|
return
|
||||||
|
app.extensions["options_monitor_started"] = True
|
||||||
|
|
||||||
|
def _bid(inst_id: str) -> float | None:
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if ex is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
|
return q.get("bid")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _positions():
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if ex is None:
|
||||||
|
return []
|
||||||
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
|
return [cfg["format_position_row"](p) for p in raw]
|
||||||
|
|
||||||
|
t = threading.Thread(
|
||||||
|
target=options_monitor_loop,
|
||||||
|
kwargs={
|
||||||
|
"enabled": True,
|
||||||
|
"poll_seconds": cfg["poll_seconds"],
|
||||||
|
"get_db": cfg["get_db"],
|
||||||
|
"fetch_positions": _positions,
|
||||||
|
"ticker_bid_fn": _bid,
|
||||||
|
"send_wechat": cfg["send_wechat"],
|
||||||
|
"account_label": cfg["account_label"],
|
||||||
|
"profit_ratio": cfg["profit_ratio"],
|
||||||
|
},
|
||||||
|
daemon=True,
|
||||||
|
name="options-monitor",
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<div class="card options-page-card" style="grid-column:1/-1" id="options-root"
|
||||||
|
data-trade-budget="{{ options_trade_budget | default(10) }}"
|
||||||
|
data-default-underly="{{ options_default_underly | default('ETH') }}">
|
||||||
|
<h2>期权(USDⓈ 本位 · 仅买方)</h2>
|
||||||
|
<p class="muted options-hint">资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。</p>
|
||||||
|
|
||||||
|
<div class="options-funds-grid">
|
||||||
|
<div class="options-funds-col">
|
||||||
|
<h3>资金账户</h3>
|
||||||
|
<div class="options-fund-row"><span>USDT</span><strong id="opt-funding-usdt">—</strong></div>
|
||||||
|
<div class="options-fund-row"><span>USDC</span><strong id="opt-funding-usdc">—</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="options-funds-col">
|
||||||
|
<h3>交易账户</h3>
|
||||||
|
<div class="options-fund-row"><span>USDT</span><strong id="opt-trading-usdt">—</strong></div>
|
||||||
|
<div class="options-fund-row"><span>USDC</span><strong id="opt-trading-usdc">—</strong></div>
|
||||||
|
<div class="options-fund-row"><span>USDG</span><strong id="opt-trading-usdg">—</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="options-funds-col options-funds-meta">
|
||||||
|
<div class="options-fund-row"><span>单笔权利金上限</span><strong id="opt-trade-budget">—</strong></div>
|
||||||
|
<button type="button" class="btn-secondary" id="opt-refresh-balances">刷新余额</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-section card-nested">
|
||||||
|
<h3>币种兑换(资金账户 USDT → USDC)</h3>
|
||||||
|
<div class="form-row options-convert-row">
|
||||||
|
<input type="number" id="opt-convert-amount" min="0" step="0.01" placeholder="USDT 数量">
|
||||||
|
<button type="button" class="btn-secondary" id="opt-convert-quote-btn">询价</button>
|
||||||
|
<button type="button" class="btn-primary" id="opt-convert-exec-btn" disabled>确认兑换</button>
|
||||||
|
</div>
|
||||||
|
<div id="opt-convert-preview" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-section card-nested">
|
||||||
|
<h3>账户划转</h3>
|
||||||
|
<div class="form-row options-transfer-row">
|
||||||
|
<select id="opt-transfer-ccy">
|
||||||
|
<option value="USDC" selected>USDC</option>
|
||||||
|
<option value="USDT">USDT</option>
|
||||||
|
</select>
|
||||||
|
<select id="opt-transfer-from">
|
||||||
|
<option value="funding" selected>资金账户</option>
|
||||||
|
<option value="trading">交易账户</option>
|
||||||
|
</select>
|
||||||
|
<span>→</span>
|
||||||
|
<select id="opt-transfer-to">
|
||||||
|
<option value="trading" selected>交易账户</option>
|
||||||
|
<option value="funding">资金账户</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" id="opt-transfer-amount" min="0" step="0.01" placeholder="数量">
|
||||||
|
<button type="button" class="btn-primary" id="opt-transfer-btn">确认划转</button>
|
||||||
|
</div>
|
||||||
|
<div id="opt-transfer-msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-section">
|
||||||
|
<div class="form-row options-chain-toolbar">
|
||||||
|
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
|
||||||
|
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
|
||||||
|
<select id="opt-exp-select"><option value="">选择到期日</option></select>
|
||||||
|
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
|
||||||
|
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
|
||||||
|
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
|
||||||
|
</div>
|
||||||
|
<div id="opt-index-line" class="muted"></div>
|
||||||
|
<div class="options-strike-table-wrap">
|
||||||
|
<table class="options-strike-table" id="opt-strike-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>行权价</th>
|
||||||
|
<th>合约</th>
|
||||||
|
<th>卖一</th>
|
||||||
|
<th>买一</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="opt-strike-tbody">
|
||||||
|
<tr><td colspan="5" class="muted">请选择到期日</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-section card-nested" id="opt-order-panel" style="display:none">
|
||||||
|
<h3>下单</h3>
|
||||||
|
<div id="opt-order-inst" class="options-order-inst"></div>
|
||||||
|
<div class="options-order-grid">
|
||||||
|
<div><span class="k">卖一(每1币)</span><span id="opt-order-ask" class="v">—</span></div>
|
||||||
|
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
|
||||||
|
<div><span class="k">ETH/BTC 数量</span><span id="opt-order-eth" class="v">—</span></div>
|
||||||
|
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label><input type="radio" name="opt-size-mode" value="budget_full" checked> 按单笔上限打满</label>
|
||||||
|
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
|
||||||
|
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
|
||||||
|
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
|
||||||
|
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
|
||||||
|
</div>
|
||||||
|
<div id="opt-order-msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="options-section">
|
||||||
|
<h3>持仓</h3>
|
||||||
|
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新持仓</button>
|
||||||
|
<div class="options-strike-table-wrap">
|
||||||
|
<table class="options-strike-table" id="opt-positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>合约</th>
|
||||||
|
<th>张数</th>
|
||||||
|
<th>币量</th>
|
||||||
|
<th>开仓均价</th>
|
||||||
|
<th>标记价</th>
|
||||||
|
<th>浮盈</th>
|
||||||
|
<th>收益率</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="opt-positions-tbody">
|
||||||
|
<tr><td colspan="8" class="muted">暂无持仓</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/options_panel.js?v=1"></script>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""期权定价单测。"""
|
||||||
|
from lib.options.options_pricing_lib import (
|
||||||
|
calc_order_size,
|
||||||
|
premium_per_sheet,
|
||||||
|
sheets_from_eth_amount,
|
||||||
|
total_premium,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_premium_per_sheet():
|
||||||
|
assert abs(premium_per_sheet(15.6, 0.01) - 0.156) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_premium_half_eth():
|
||||||
|
assert abs(total_premium(15.6, 0.5) - 7.8) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_sheets_from_eth():
|
||||||
|
assert sheets_from_eth_amount(0.5, 0.01) == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_order_size_budget():
|
||||||
|
r = calc_order_size(
|
||||||
|
quote_per_unit=15.6,
|
||||||
|
ct_mult=0.01,
|
||||||
|
min_sz=1,
|
||||||
|
budget_usdc=10,
|
||||||
|
budget_buffer=0.95,
|
||||||
|
budget_cap=10,
|
||||||
|
)
|
||||||
|
assert r["ok"] is True
|
||||||
|
assert r["sheets"] >= 1
|
||||||
|
assert r["total_premium"] <= 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_order_size_too_small():
|
||||||
|
r = calc_order_size(
|
||||||
|
quote_per_unit=2000.0,
|
||||||
|
ct_mult=0.01,
|
||||||
|
min_sz=1,
|
||||||
|
budget_usdc=10,
|
||||||
|
budget_buffer=0.95,
|
||||||
|
budget_cap=10,
|
||||||
|
)
|
||||||
|
assert r["ok"] is False
|
||||||
Reference in New Issue
Block a user