diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index f425a95..52c5b9d 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -100,6 +100,25 @@ OKX_POSITION_INST_TYPE=SWAP # 企业微信推送里展示的账户备注 # 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) # ============================================================================= diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index fb5bf9f..54f551c 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -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_SECRET = os.getenv("OKX_API_SECRET", "") 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_POS_MODE = os.getenv("OKX_POS_MODE", "hedge") 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.secret = OKX_API_SECRET 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 ACCOUNT_BALANCE_CACHE = { "updated_at": 0.0, @@ -1467,8 +1487,10 @@ def init_db(): ) from lib.strategy.strategy_db import init_strategy_tables + from lib.options.options_db import init_options_tables init_strategy_tables(conn) + init_options_tables(conn) from lib.trade.account_risk_lib import ensure_account_risk_schema ensure_account_risk_schema(conn) @@ -6660,6 +6682,9 @@ def render_main_page(page="trade", embed_mode=None): key_rule_ctx=key_rule_ctx, funds_fmt=format_funds_u, 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, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, @@ -6739,6 +6764,15 @@ def settings_page(): 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") @login_required 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") _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") +_OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.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") +@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/") @login_required 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_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() diff --git a/docs/期权方案.md b/docs/期权方案.md new file mode 100644 index 0000000..4e35e08 --- /dev/null +++ b/docs/期权方案.md @@ -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` diff --git a/docs/期权用法.md b/docs/期权用法.md new file mode 100644 index 0000000..fe51041 --- /dev/null +++ b/docs/期权用法.md @@ -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. 风险说明 + +- 买方最大亏损为 **权利金**;近期实值仍会时间衰减 +- 限价单可能因无流动性未成交 +- 请先在小额下验证兑换、划转、开平仓全流程 diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 987813b..fa7a119 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2326,3 +2326,68 @@ html[data-theme="light"] .settings-export-link { 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; +} + diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js new file mode 100644 index 0000000..e3b74c9 --- /dev/null +++ b/lib/common/static/options_panel.js @@ -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 = ''; + 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 = '请选择到期日'; + 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 = '无符合的实值合约'; + return; + } + list.forEach(function (c) { + const tr = document.createElement("tr"); + tr.innerHTML = + "" + c.strike + "" + + "" + c.inst_id + "" + + "" + fmt(c.ask, 4) + "" + + "" + fmt(c.bid, 4) + "" + + ''; + 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 = '暂无持仓'; + return; + } + list.forEach(function (p) { + const tr = document.createElement("tr"); + tr.innerHTML = + "" + (p.inst_id || "") + "" + + "" + fmt(p.pos, 0) + "" + + "" + fmt(p.eth_amount, 4) + "" + + "" + fmt(p.avg_px, 4) + "" + + "" + fmt(p.mark_px, 4) + "" + + "" + fmt(p.upl, 4) + "" + + "" + (p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "" + + ''; + 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(); +})(); diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py new file mode 100644 index 0000000..346c8f8 --- /dev/null +++ b/lib/exchange/okx_options_lib.py @@ -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, "" diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 84e96fd..ad4bc11 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -15,6 +15,7 @@ EMBED_TABS: tuple[str, ...] = ( "trade", "strategy", "strategy_records", + "options", "records", "stats", "settings", @@ -28,6 +29,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = { "/strategy/trend": "strategy", "/strategy/roll": "strategy", "/strategy/records": "strategy_records", + "/options": "options", "/records": "records", "/stats": "stats", "/settings": "settings", diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index d3d689f..f8d2c55 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -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 = "" if trade_policy and getattr(trade_policy, "badge_text", ""): policy_note = str(trade_policy.badge_text) diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 0f5f453..f06d318 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -206,6 +206,8 @@ {% include 'strategy_trading_page.html' %} {% elif page == 'strategy_records' %} {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} {% endif %} diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 8cfcd67..575d2ca 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -36,6 +36,9 @@ {% endif %} 交易记录与复盘 统计分析 + {% if options_enabled %} + 期权 + {% endif %} 系统设置 diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index c2255bc..080fb20 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -17,7 +17,7 @@ {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - + 交易记录与复盘 统计分析 + {% if options_enabled %} + 期权 + {% endif %} 系统设置 {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} - {% if page != 'settings' %} + {% if page != 'settings' and page != 'options' %} {% include 'instance_header_panel.html' %} {% endif %} - {% if page != 'settings' %} + {% if page != 'settings' and page != 'options' %} {% include 'instance_top_bar.html' %} {% endif %} @@ -275,6 +278,8 @@ {% include 'strategy_trading_page.html' %} {% elif page == 'strategy_records' %} {% include 'strategy_records_page.html' %} + {% elif page == 'options' %} + {% include 'options_panel.html' %} {% endif %} diff --git a/lib/options/__init__.py b/lib/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/options/options_db.py b/lib/options/options_db.py new file mode 100644 index 0000000..db1ff3d --- /dev/null +++ b/lib/options/options_db.py @@ -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 + ) + """ + ) diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py new file mode 100644 index 0000000..0aa8e08 --- /dev/null +++ b/lib/options/options_monitor_lib.py @@ -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))) diff --git a/lib/options/options_pricing_lib.py b/lib/options/options_pricing_lib.py new file mode 100644 index 0000000..882fdfe --- /dev/null +++ b/lib/options/options_pricing_lib.py @@ -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 diff --git a/lib/options/options_register.py b/lib/options/options_register.py new file mode 100644 index 0000000..82e6690 --- /dev/null +++ b/lib/options/options_register.py @@ -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() diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html new file mode 100644 index 0000000..bbd0ad4 --- /dev/null +++ b/lib/options/templates/options_panel.html @@ -0,0 +1,128 @@ +
+

期权(USDⓈ 本位 · 仅买方)

+

资金账户兑换 USDT→USDC 后,划转到交易账户即可买入。报价单位为每 1 ETH/BTC;1 张 = 0.01 ETH/BTC。

+ +
+
+

资金账户

+
USDT
+
USDC
+
+
+

交易账户

+
USDT
+
USDC
+
USDG
+
+
+
单笔权利金上限
+ +
+
+ +
+

币种兑换(资金账户 USDT → USDC)

+
+ + + +
+
+
+ +
+

账户划转

+
+ + + + + + +
+
+
+ +
+
+ + + + + + +
+
+
+ + + + + + + + + + + + + +
行权价合约卖一买一操作
请选择到期日
+
+
+ + + +
+

持仓

+ +
+ + + + + + + + + + + + + + + + +
合约张数币量开仓均价标记价浮盈收益率操作
暂无持仓
+
+
+
+ diff --git a/tests/test_options_pricing.py b/tests/test_options_pricing.py new file mode 100644 index 0000000..5f3cab9 --- /dev/null +++ b/tests/test_options_pricing.py @@ -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