diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 8fe87cc..fb1da1f 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -10042,14 +10042,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance - funding_capital, trading_capital = get_exchange_capitals(force=False) + funding_capital, trading_capital = get_exchange_capitals(force=True) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, "trading_day": get_trading_day(app_now()), } diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index cdfe660..6c611ed 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -467,8 +467,6 @@ from lib.exchange.gate_ccxt_lib import gate_ccxt_class # Gate.io USDT 永续(swap) exchange = gate_ccxt_class()({ "enableRateLimit": True, - # 避免关键位监控/账户拉取无限挂起拖垮中控 - "timeout": int(os.getenv("GATE_CCXT_TIMEOUT_MS", "8000")), "options": { "defaultType": "swap", "defaultMarginMode": _GATE_DEFAULT_MARGIN_MODE, @@ -2834,13 +2832,13 @@ def friendly_exchange_error(err, available_usdt=None): return f"交易所下单失败:{clean}" -_BALANCE_REFRESH_LOCK = threading.Lock() -_BALANCE_REFRESH_INFLIGHT = False - - -def _fetch_exchange_capitals_sync(): - """同步拉取资金/交易账户余额并写入缓存(会占用 ccxt,勿在中控热路径直接调用).""" +def get_exchange_capitals(force=False): + ok_live, _ = ensure_exchange_live_ready() + if not ok_live: + return None, None now_ts = time.time() + if (not force) and ACCOUNT_BALANCE_CACHE["updated_at"] and now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS: + return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] try: ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt() except Exception: @@ -2854,43 +2852,6 @@ def _fetch_exchange_capitals_sync(): return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] -def _kick_exchange_capitals_refresh(): - """后台刷新余额;与 key_rs 争用时不阻塞看板/中控请求.""" - global _BALANCE_REFRESH_INFLIGHT - with _BALANCE_REFRESH_LOCK: - if _BALANCE_REFRESH_INFLIGHT: - return - _BALANCE_REFRESH_INFLIGHT = True - - def _worker(): - global _BALANCE_REFRESH_INFLIGHT - try: - _fetch_exchange_capitals_sync() - except Exception: - pass - finally: - with _BALANCE_REFRESH_LOCK: - _BALANCE_REFRESH_INFLIGHT = False - - threading.Thread(target=_worker, name="gate-balance-refresh", daemon=True).start() - - -def get_exchange_capitals(force=False): - ok_live, _ = ensure_exchange_live_ready() - if not ok_live: - return None, None - now_ts = time.time() - has_cache = bool(ACCOUNT_BALANCE_CACHE["updated_at"]) - fresh = has_cache and (now_ts - ACCOUNT_BALANCE_CACHE["updated_at"] < BALANCE_REFRESH_SECONDS) - if (not force) and fresh: - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - if not force: - # 过期/冷缓存:先返回现有值(可空),后台刷新,避免 /api/hub/account 被 RS 监控拖死 - _kick_exchange_capitals_refresh() - return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"] - return _fetch_exchange_capitals_sync() - - def execute_transfer_usdt(amount, from_account, to_account): from lib.exchange.gate_transfer_lib import execute_transfer_usdt as _gate_execute_transfer_usdt @@ -4650,25 +4611,14 @@ def _finalize_key_monitor_one_shot(conn, row, last_msg, close_reason): conn.execute("DELETE FROM key_monitors WHERE id=?", (row["id"],)) -_RS_BAR_CACHE: dict[str, dict] = {} -_RS_BAR_CACHE_TTL_SEC = float(os.getenv("GATE_RS_BAR_CACHE_SEC", "45")) - - def _fetch_last_closed_bar(symbol): - """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.短缓存减轻关键位监控打爆 ccxt.""" + """最近一根闭合 K:[ts, o, h, l, c, v] 或 None.""" ex_sym = normalize_exchange_symbol(symbol) - now = time.time() - cached = _RS_BAR_CACHE.get(ex_sym) - if cached and now - float(cached.get("updated_at") or 0) < _RS_BAR_CACHE_TTL_SEC: - return cached.get("bar") bars = exchange.fetch_ohlcv(ex_sym, timeframe=KLINE_TIMEFRAME, limit=5) or [] if len(bars) < 2: - _RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": None} return None closed = bars[:-1] - bar = closed[-1] if closed else None - _RS_BAR_CACHE[ex_sym] = {"updated_at": now, "bar": bar} - return bar + return closed[-1] if closed else None def _key_rs_gate_preview(symbol, upper, lower): @@ -9943,14 +9893,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - # 中控看板高频拉取:仅走余额缓存;不再额外 fetch_balance(会与关键位监控争用 ccxt) - funding_capital, trading_capital = get_exchange_capitals(force=False) + funding_capital, trading_capital = get_exchange_capitals(force=True) funding_usdt = round(funding_capital, 2) if funding_capital is not None else None trading_usdt = round(trading_capital, 2) if trading_capital is not None else None + available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": trading_usdt, + "available_trading_usdt": round(available, 2) if available is not None else None, "trading_day": get_trading_day(app_now()), } diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index c7be6b0..7a609be 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -9665,14 +9665,14 @@ def _hub_meta_bundle(): def _hub_account_bundle(): - # 中控看板高频拉取:仅走余额缓存,避免额外 fetch_balance - funding_capital, trading_capital = get_exchange_capitals(force=False) + funding_capital, trading_capital = get_exchange_capitals(force=True) funding_usdt = round(funding_capital, FUNDS_DECIMALS) if funding_capital is not None else None trading_usdt = round(trading_capital, FUNDS_DECIMALS) if trading_capital is not None else None + available = get_available_trading_usdt() return { "funding_usdt": funding_usdt, "trading_usdt": trading_usdt, - "available_trading_usdt": trading_usdt, + "available_trading_usdt": round(available, FUNDS_DECIMALS) if available is not None else None, "trading_day": get_trading_day(app_now()), } diff --git a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md deleted file mode 100644 index bfa2a1e..0000000 --- a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R1.md +++ /dev/null @@ -1,53 +0,0 @@ -# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 1 轮) - -## 背景 - -期权链接入 OKX WS 推送后,生产中控出现「期权数据不可用 / 子代理不可用」。按要求**先回滚 WS 链路**,再全量审计并修复。 - -## 回滚 - -| 提交 | 说明 | -|------|------| -| `a488e2f` | Revert fast-path(依赖 WS 热缓存) | -| `d592632` | Revert OKX WS + SSE 推送整栈 | - -恢复为 **REST 拉链 + 前端约 15s soft-poll**(commit `24bb853` 行为),删除: - -- `lib/exchange/okx_public_ws_lib.py` -- `lib/options/options_quote_live_lib.py` -- `tests/test_options_quote_live_lib.py` - -## 根因结论(非仅 WS) - -| 级别 | 问题 | 证据 | -|------|------|------| -| Critical | Gate 关键位 RS 监控在后台线程高频 `fetch_ohlcv`,与 `/api/hub/account` 争用同一 ccxt 客户端,账户/子代理超时 | 日志 `[key_rs_level_alert] BTC/USDT id=13`;本机 `5000/api/hub/account` 25s 超时 | -| Critical | 中控期权快照对每仓拉 books 深度,易超 `HUB_FLASK_TIMEOUT=10` | `build_display_option_positions` → `attach_close_preview` → `fetch_option_book_depth` | -| High | Soft 拉链 3 次重试 + 无单飞,易与 SSE tick 叠打 OKX | `options_panel.js` loadChain | -| High | 中控账户接口每轮 `force=True` 绕过余额缓存 | Gate/OKX/Binance `_hub_account_bundle` | -| Medium | Flask 超时错误只有 `error` 无 `msg`,前端易落默认文案 | `hub.py` `_fetch_flask_json` | -| Medium | `options` 为 null 时前端当成「0 仓」而非不可用 | `app.js` renderOptionsSectionBody | - -WS 部署触发的**全进程重启**放大了 Gate 争用与快照超时,表现为「全不可用」;OKX 快照在轻负载下仍可 `ok:true`。 - -## 本轮修复 - -1. **Hub 期权快照**关闭逐仓 `close_preview`/books(`with_close_preview=False`) -2. **Hub 账户**三所改为 `get_exchange_capitals(force=False)` -3. **Gate ccxt** 增加 `timeout=8000ms`;RS K 线 **45s 缓存** -4. **期权 tickers** 恢复 **10s** 短缓存(无 WS) -5. **前端 soft 拉链**:单飞 + soft 仅 1 次尝试;已有链不先清空表格 -6. **Hub**:超时补 `msg`;期权快照与 account/monitor **并行 gather** -7. **中控 UI**:capabilities 含 options 且 snapshot 缺失时显式「期权数据不可用」 - -## 测试建议 - -- 强刷中控监控区:OKX 期权资金/持仓应恢复;Gate 子代理 status 应在数秒内恢复 -- 期权页「刷新链」不应长时间白屏;指数行显示约 15s 静默刷新 -- Gate 关键位监控日志不应再每秒刷屏 `fetch_ohlcv` 失败 - -## 残留风险(交第 2 轮) - -- Gate 仍与监控共用单一 ccxt 客户端(未加全局锁) -- 中控 board 仍可能被最慢交易所拉长整轮等待 -- Soft-poll 仍是 REST,非真·实时 diff --git a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md b/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md deleted file mode 100644 index da1f87f..0000000 --- a/docs/审计修复报告-WS回滚与中控可用-2026-08-11-R2.md +++ /dev/null @@ -1,41 +0,0 @@ -# 审计修复报告 · WS 回滚与中控可用性(2026-08-11 · 第 2 轮) - -## 范围 - -复查第 1 轮修复是否引入回归,并扫清仍会导致「中控不可用」的残留高优先级问题。 - -## 复查结论 - -| 项 | 结论 | -|----|------| -| Hub 并行 options 索引进位 | 正确(day / options 组合无错位) | -| Hub 关闭 close_preview | UI 降级为 upl/`—`,不崩 | -| Gate RS 缓存 / timeout | timeout 已为 int;缓存可接受 | -| board row capabilities | `_fetch_agent_status` 始终带上 | -| `with_close_preview` 默认 | 实例路径仍为 True | - -## 本轮新发现问题与修复 - -| 级别 | 问题 | 修复 | -|------|------|------| -| High | Hub 账户在 `force=False` 后仍调用 `get_available_trading_usdt()` 再打一枪 `fetch_balance`,Gate 争用依旧 | 三所 `_hub_account_bundle` 改为用缓存的 `trading_usdt` 作为 `available_trading_usdt` | -| Medium | `loadChain` soft 门禁在 `seq++` 之后,叠刷可导致 `chainLoadInFlight` 永不清理 | soft 门禁移到 `seq++` 之前 | - -## 与第 1 轮一并交付的状态 - -- WS 推送链路已回滚(REST + 15s soft-poll) -- 中控期权快照轻量化 + 并行拉取 -- Gate RS K 线短缓存 + ccxt timeout -- 期权 tickers 10s 缓存;soft 单飞/单次尝试 - -## 已知残留(不阻塞本次部署) - -- Gate 监控与账户仍共用单一 ccxt 客户端(无全局锁) -- 中控 board 仍可能被最慢交易所拉长整轮 -- Soft-poll 非真·实时报价 - -## 部署后验收 - -1. 中控强刷:OKX 期权区有资金数字,不再长期「期权数据不可用」 -2. Gate 卡:子代理恢复绿色/有资金;不再长时间「子代理不可用」 -3. 期权页刷新链不白屏;约 15s 静默更新时间戳 diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 868543b..dd2ecf7 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -1165,9 +1165,14 @@ fillExpSelect($("hp-oo-exp-select"), d); renderListStrikes(); renderTStrikes(); - // 期期盈亏比默认 2,不再用指数自动填上破/下破 - if ($("hp-oo-rr") && !$("hp-oo-rr").value) { - $("hp-oo-rr").value = "2"; + if (d.index_px) { + const idx = Number(d.index_px); + if ($("hp-target-up") && !$("hp-target-up").value) { + $("hp-target-up").value = String(Math.round(idx * 1.03)); + } + if ($("hp-target-down") && !$("hp-target-down").value) { + $("hp-target-down").value = String(Math.round(idx * 0.97)); + } } } @@ -1576,7 +1581,8 @@ if ($("hp-contracts")) $("hp-contracts").value = ""; if ($("hp-tp")) $("hp-tp").value = ""; if ($("hp-sl")) $("hp-sl").value = ""; - if ($("hp-oo-rr")) $("hp-oo-rr").value = "2"; + if ($("hp-target-up")) $("hp-target-up").value = ""; + if ($("hp-target-down")) $("hp-target-down").value = ""; if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—"; if ($("hp-premium-line")) $("hp-premium-line").textContent = ""; if ($("hp-oo-sheets-a")) { @@ -1612,12 +1618,16 @@ if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { throw new Error("期期两腿须为平值或虚值,不可选实值"); } - const rr = numInput("hp-oo-rr", 2); - if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); body = { plan_type: "options_options", - oo_profit_rr: rr, - index_px: indexPx() || 0, + target_price_up: up, + target_price_down: down, + target_price: up, + index_px: indexPx() || (up + down) / 2, leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")), }; @@ -1709,44 +1719,28 @@ fmt(s.premium_paid) + (s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : ""); } else { - const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target; - const tgt = s.target_profit != null ? s.target_profit : s.at_target_total; - if (rr != null) { - summary.innerHTML = - "盈亏比 ×" + - fmt(rr, 2) + - " · 目标盈利 " + - fmtPnlHtml(tgt) + - " · 到期现价 " + - fmtPnlHtml(s.expiry_flat_total) + - " · 保费 " + - fmt(s.premium_paid) + - '(达标全平;不达标等到期)' + - (s.expiry_is_loss ? " · 到期现价情景为亏" : ""); - } else { - const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; - const dnTot = s.at_target_down_total; - let rrLine = ""; - if (s.rr_at_up != null || s.rr_at_down != null) { - rrLine = - " · 盈亏比 上破 " + - fmtRr(s.rr_at_up) + - (dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + - '(亏=全额保费 ' + - fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + - ")"; - } - summary.innerHTML = - "上破 " + - fmtPnlHtml(upTot) + - (dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") + - " · 到期现价 " + - fmtPnlHtml(s.expiry_flat_total) + - " · 保费 " + - fmt(s.premium_paid) + - rrLine + - (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); + const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total; + const dnTot = s.at_target_down_total; + let rrLine = ""; + if (s.rr_at_up != null || s.rr_at_down != null) { + rrLine = + " · 盈亏比 上破 " + + fmtRr(s.rr_at_up) + + (dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") + + '(亏=全额保费 ' + + fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) + + ")"; } + summary.innerHTML = + "上破 " + + fmtPnlHtml(upTot) + + (dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") + + " · 到期现价 " + + fmtPnlHtml(s.expiry_flat_total) + + " · 保费 " + + fmt(s.premium_paid) + + rrLine + + (s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : ""); } } if (!tbody) return; @@ -2063,7 +2057,8 @@ "hp-tp", "hp-sl", "hp-sheets", - "hp-oo-rr", + "hp-target-up", + "hp-target-down", ]); if ($("hp-preview-btn")) $("hp-preview-btn").addEventListener("click", function () { @@ -2176,9 +2171,6 @@ if (p.plan_type === "perp_options") { return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl); } - if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) { - return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)"; - } return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price); } @@ -2338,8 +2330,6 @@ target_win_leg: "期期平盈利腿", target_up_win_leg: "期期上破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿", - oo_rr_target: "期期盈亏比达标", - oo_rr_closing: "期期盈亏比平仓中", oo_rest_closing: "期期全平·清残腿中", oo_rest_closed: "期期全平·两腿已平", orphaned_after_tp: "止盈后持有至到期", @@ -2414,11 +2404,6 @@ "x · 张数 " + fmt(p.perp_size, 4) + ""; - } else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) { - html += - "
盈亏比 ×" + - fmt(p.oo_profit_rr, 2) + - "(浮盈达标全平;不达标等到期)
"; } else { html += "
目标价 上破 " + @@ -2641,13 +2626,17 @@ if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) { throw new Error("期期两腿须为平值或虚值,不可选实值"); } - const rr = numInput("hp-oo-rr", 2); - if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)"); + const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0); + const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0); + if (!up || !down) throw new Error("请填写上破与下破目标价"); + if (up <= down) throw new Error("上破目标价必须大于下破目标价"); body = { plan_type: "options_options", underlying: state.underlying, - oo_profit_rr: rr, - index_px: indexPx() || 0, + target_price_up: up, + target_price_down: down, + target_price: up, + index_px: indexPx() || (up + down) / 2, oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry", oo_sheets_mode: state.ooSheetsMode || "same_sheets", leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")), diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 7085b12..30e00f4 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -37,15 +37,9 @@ let selectSeq = 0; let refreshAllTimer = null; let pendingRefreshTimer = null; - let chainSoftTimer = null; - let lastChainSoftAt = 0; - let chainQuotedAt = 0; - let chainLoadInFlight = false; let pendingTtlSeconds = 600; const POSITIONS_STALE_MS = 45000; const PENDING_POLL_MS = 8000; - /** 链卖一/买一静默刷新节流:无推送,靠拉;过密会撞 OKX 50011 */ - const CHAIN_SOFT_POLL_MS = 15000; const orderPanelHome = (function () { const host = document.getElementById("opt-order-panel-host"); return host ? host.parentElement : null; @@ -327,7 +321,7 @@ [ "opt-sheets-amount", "opt-eth-amount", - "opt-profit-rr", + "opt-target-idx", ].forEach(function (id) { harden(document.getElementById(id)); }); @@ -638,15 +632,6 @@ if (el) el.textContent = fmt(buf, 2); } - function fmtChainQuotedAt() { - if (!chainQuotedAt) return ""; - const d = new Date(chainQuotedAt); - const pad = function (n) { - return n < 10 ? "0" + n : String(n); - }; - return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); - } - function renderIndexLine() { const idx = state.chain && state.chain.index_px; const dte = state.chain && state.chain.chain_max_dte_days; @@ -660,37 +645,12 @@ const line = document.getElementById("opt-index-line"); if (line) { const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)"; - const ageHint = chainQuotedAt ? " · 链报价 " + fmtChainQuotedAt() + "(约每15s静默刷新)" : ""; line.textContent = "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + - " · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + ageHint; + " · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外"; } } - function softRefreshChainThrottled(force) { - if (document.hidden) return; - if (!document.getElementById("options-root")) return; - if (chainLoadInFlight) return; - const now = Date.now(); - if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return; - lastChainSoftAt = now; - void loadChain({ soft: true }); - } - - function startChainSoftPoll() { - if (chainSoftTimer) return; - chainSoftTimer = setInterval(function () { - if (!document.getElementById("options-root")) { - if (chainSoftTimer) { - clearInterval(chainSoftTimer); - chainSoftTimer = null; - } - return; - } - softRefreshChainThrottled(false); - }, CHAIN_SOFT_POLL_MS); - } - function pickNearestExpiry(exps) { if (!exps || !exps.length) return ""; const now = Date.now(); @@ -934,10 +894,11 @@ } function updateOrderEstimates() { + const levEl = document.getElementById("opt-order-leverage"); const valueEl = document.getElementById("opt-est-value"); const profitEl = document.getElementById("opt-est-profit"); - const levEl = document.getElementById("opt-order-leverage"); - const rrEl = document.getElementById("opt-profit-rr"); + const targetLevEl = document.getElementById("opt-est-leverage"); + const targetEl = document.getElementById("opt-target-idx"); const q = state.orderQuote; if (!q || !q.ok || !q.can_open) { if (levEl) levEl.textContent = "—"; @@ -946,6 +907,7 @@ profitEl.textContent = "—"; profitEl.className = "v"; } + if (targetLevEl) targetLevEl.textContent = "—"; return; } const sz = q.sizing || {}; @@ -954,19 +916,30 @@ const lev = calcContractLeverage(q.index_px, ethAmount, premium); if (levEl) levEl.textContent = fmtLeverage(lev); - if (valueEl && profitEl && rrEl) { - const rrRaw = rrEl.value; - const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw); - if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) { + if (valueEl && profitEl && targetEl) { + const targetRaw = targetEl.value; + if (targetRaw === "" || targetRaw == null) { valueEl.textContent = "—"; profitEl.textContent = "—"; profitEl.className = "v"; + if (targetLevEl) targetLevEl.textContent = "—"; } else { - const targetProfit = Number(premium) * rr; - const needRecycle = Number(premium) + targetProfit; - valueEl.textContent = fmtUsdc(needRecycle) + " USDC"; - profitEl.textContent = fmtUsdcSigned(targetProfit); - profitEl.className = "v " + pnlCls(targetProfit); + const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); + const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); + if (value == null || Number.isNaN(value)) { + valueEl.textContent = "—"; + } else { + valueEl.textContent = fmtUsdc(value) + " USDC"; + } + if (profit == null || Number.isNaN(profit)) { + profitEl.textContent = "—"; + profitEl.className = "v"; + } else { + profitEl.textContent = fmtUsdcSigned(profit); + profitEl.className = "v " + pnlCls(profit); + } + const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium); + if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev); } } } @@ -1241,16 +1214,11 @@ async function loadChain(opts) { const soft = !!(opts && opts.soft); - // soft 门禁必须在 seq++ 之前,否则叠刷会抬高 seq 导致 inFlight 永不清理 - if (chainLoadInFlight && soft) return; const uly = state.underlying; const seq = ++chainLoadSeq; const btn = document.getElementById("opt-load-chain"); - const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly; - chainLoadInFlight = true; if (btn && !soft) btn.disabled = true; - // 已有链时不先清空,避免刷新白屏 - if (!soft && !hadChain) { + if (!soft) { setExpirySelectStatus("加载到期日中…"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { @@ -1261,27 +1229,16 @@ try { let d = null; let lastMsg = ""; - // soft 只试 1 次,避免与 15s 轮询叠加重试打爆 OKX - const maxAttempts = soft ? 1 : 3; - for (let attempt = 0; attempt < maxAttempts; attempt++) { + for (let attempt = 0; attempt < 2; attempt++) { if (seq !== chainLoadSeq) return; d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); if (seq !== chainLoadSeq) return; if (d && d.ok && chainHasExpiries(d)) break; lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; - const rateLimited = - !!(d && d.rate_limited) || - /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")); d = null; - if (attempt < maxAttempts - 1) { - if (!soft && !hadChain) { - setExpirySelectStatus( - rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…" - ); - } - await new Promise(function (resolve) { - setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 400); - }); + if (attempt === 0) { + if (!soft) setExpirySelectStatus("重试加载到期日…"); + await new Promise(function (resolve) { setTimeout(resolve, 400); }); } } if (seq !== chainLoadSeq) return; @@ -1296,19 +1253,13 @@ if (soft) return; setExpirySelectStatus("选择到期日"); const tbody = document.getElementById("opt-strike-tbody"); - const friendly = - /50011|Too Many Requests|过于频繁/i.test(String(lastMsg || "")) - ? "OKX 请求过于频繁,请稍后再点「刷新链」" - : lastMsg || "暂无到期日,请点「刷新链」"; if (tbody) { tbody.innerHTML = - '' + - friendly + + '' + + (lastMsg || "暂无到期日,请点「刷新链」") + ""; } - alert(friendly); + alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); return; } const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; @@ -1316,8 +1267,6 @@ panelCache.chain = d; panelCache.underlying = uly; panelCache.optType = state.optType; - chainQuotedAt = Date.now(); - lastChainSoftAt = chainQuotedAt; syncAskLiqFilterFromChain(d); if (!soft) { state.selectedInst = null; @@ -1348,10 +1297,7 @@ ""; } } finally { - if (seq === chainLoadSeq) { - chainLoadInFlight = false; - if (btn) btn.disabled = false; - } + if (seq === chainLoadSeq && btn) btn.disabled = false; } } @@ -1383,13 +1329,15 @@ } else if (mode === "sheets") { body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); } - const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim(); - const rr = rrRaw === "" ? 2 : parseFloat(rrRaw); - if (!Number.isFinite(rr) || rr <= 0) { - alert("盈亏比无效"); - return false; + const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); + if (tgtRaw !== "") { + const tgt = parseFloat(tgtRaw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("目标位无效"); + return false; + } + body.target_index = tgt; } - body.profit_rr = rr; const d = await apiJson("/api/options/open", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1480,17 +1428,15 @@ return null; } - function formatRrEstimateHtml(rr, premiumPaid) { - const r = Number(rr); - const prem = Number(premiumPaid); - if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return ""; - const profit = Math.round(prem * r * 100) / 100; - const need = Math.round((prem + profit) * 100) / 100; + function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { + const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); + const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); + if (value == null && profit == null) return ""; let html = ''; - html += '目标盈利' + - fmtUsdcSigned(profit) + ""; - html += '需回收' + - fmtUsdc(need) + " USDC"; + html += '价值' + + (value == null ? "—" : fmtUsdc(value) + " USDC") + ""; + html += '预估盈利' + + (profit == null ? "—" : fmtUsdcSigned(profit)) + ""; html += ""; return html; } @@ -1498,73 +1444,47 @@ function renderTargetDelegateRow(p) { const inst = p.inst_id || ""; const hedgeTarget = p.hedge_plan_target || null; - if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") { - const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null; - const armedTxt = - rr != null && Number.isFinite(rr) && rr > 0 - ? "盈亏比 ×" + fmt(rr, 2) - : hedgeTarget.target_index != null - ? "目标 " + fmt(hedgeTarget.target_index, 1) - : "托管中"; + if (hedgeTarget && Number(hedgeTarget.target_index) > 0) { + const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; return ( '
' + '对冲计划' + '计划 #' + hedgeTarget.plan_id + " · " + - armedTxt + + side + + " " + + fmt(hedgeTarget.target_index, 1) + "" + - '进行中 · 由对冲计划监控' + + '进行中 · 由对冲计划监控,到位后仅平盈利腿' + "
" ); } - const rrArmed = - p.profit_rr != null && p.profit_rr !== "" - ? Number(p.profit_rr) - : p.target_monitor && p.target_monitor.profit_rr != null - ? Number(p.target_monitor.profit_rr) - : null; - const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0; + const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; + const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; + const ethAmt = posEthAmount(p); const prem = p.premium_paid; - const draft = - state.targetDraftByInst[inst] != null - ? String(state.targetDraftByInst[inst]) - : armed - ? String(rrArmed) - : "2"; const estHtml = armed - ? formatRrEstimateHtml(rrArmed, prem) + ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) : ''; return ( - '
' + + '
' + '委托' + - '' + - '' + - '" + - (armed ? '盈亏比 ×' + fmt(rrArmed, 2) + "" : "") + + '' + + '' + + '" + + (armed + ? '目标 ' + fmt(tgt, 1) + "" + : "") + estHtml + '' + - (armed - ? "监控中 · 买一浮盈达盈亏比后全平" - : "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") + + (armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") + "" + "
" ); @@ -1576,14 +1496,20 @@ if (!est) return; const inp = row.querySelector(".opt-pos-target-input"); const typed = inp ? String(inp.value || "").trim() : ""; - const armed = row.getAttribute("data-armed-rr") || ""; - const rrRaw = typed !== "" ? typed : armed; - if (rrRaw === "") { + const armed = row.getAttribute("data-armed-target") || ""; + const targetRaw = typed !== "" ? typed : armed; + if (targetRaw === "") { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; return; } - const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem")); + const html = formatTargetEstimateHtml( + row.getAttribute("data-opt-type"), + row.getAttribute("data-strike"), + targetRaw, + row.getAttribute("data-eth"), + row.getAttribute("data-prem") + ); if (!html) { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; @@ -1715,9 +1641,9 @@ const row = card ? card.querySelector(".opt-target-row") : null; const inp = card ? card.querySelector(".opt-pos-target-input") : null; const raw = inp ? String(inp.value || "").trim() : ""; - const rr = raw === "" ? 2 : parseFloat(raw); - if (!Number.isFinite(rr) || rr <= 0) { - alert("请输入有效盈亏比(相对权利金,默认2)"); + const tgt = parseFloat(raw); + if (!Number.isFinite(tgt) || tgt <= 0) { + alert("请输入有效目标指数价"); return; } if (btn) btn.disabled = true; @@ -1725,16 +1651,17 @@ const d = await apiJson("/api/options/target", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ inst_id: inst, profit_rr: rr }), + body: JSON.stringify({ inst_id: inst, target_index: tgt }), }); if (!d.ok) { alert(d.msg || "设定失败"); return; } delete state.targetDraftByInst[inst]; - if (inp) inp.value = String(rr); + if (inp) inp.value = ""; if (row) { - row.setAttribute("data-armed-rr", String(rr)); + row.setAttribute("data-armed-target", String(tgt)); + updatePosTargetEstimate(row); } await refreshAllPositions(); } finally { @@ -1774,22 +1701,12 @@ } box.hidden = false; host.innerHTML = rows.map(function (t) { + const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; const managed = t.managed_by === "hedge_plan"; - let rule; - if (t.profit_rr != null && Number(t.profit_rr) > 0) { - rule = "盈亏比 ×" + fmt(t.profit_rr, 2); - } else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) { - rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2); - } else if (t.target_index != null && Number(t.target_index) > 0) { - const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; - rule = side + " " + fmt(t.target_index, 1); - } else { - rule = "委托中"; - } return ( '
' + '' + (t.inst_id || "") + "" + - '' + rule + "" + + '' + side + " " + fmt(t.target_index, 1) + "" + (managed ? '对冲计划 #' + (t.plan_id || "") + " · 进行中" : '') + @@ -1970,23 +1887,20 @@ paintPositions(list); const fromPos = list.reduce(function (targets, p) { if (!p) return targets; - if (p.profit_rr != null || p.target_index != null) { + if (p.target_index != null) { targets.push({ id: p.target_monitor_id, inst_id: p.inst_id, opt_type: p.opt_type, target_index: p.target_index, - profit_rr: p.profit_rr, }); } const hedgeTarget = p.hedge_plan_target; - if (hedgeTarget) { + if (hedgeTarget && hedgeTarget.target_index != null) { targets.push({ inst_id: p.inst_id, opt_type: p.opt_type || hedgeTarget.opt_type, target_index: hedgeTarget.target_index, - oo_profit_rr: hedgeTarget.oo_profit_rr, - profit_rr: hedgeTarget.oo_profit_rr, plan_id: hedgeTarget.plan_id, managed_by: hedgeTarget.managed_by, }); @@ -2258,7 +2172,6 @@ updateUnderlyingLabel(); refreshPendingOrders(); startPendingOrdersPoll(); - startChainSoftPoll(); const hasCache = chainHasExpiries(panelCache.chain) && panelCache.underlying === state.underlying && @@ -2268,8 +2181,8 @@ renderExpiries(); renderStrikes(); refreshAllPositions(); - // 后台静默刷新,避免缓存过期后到期日变空 / 卖一过期 - softRefreshChainThrottled(true); + // 后台静默刷新,避免缓存过期后到期日变空 + loadChain({ soft: true }); return; } requestAnimationFrame(function () { @@ -2373,17 +2286,17 @@ } bindOrderDialogChrome(); - ["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) { + ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", function () { - if (id === "opt-profit-rr") { + if (id === "opt-target-idx") { updateEstimatedProfit(); return; } if (state.selectedInst) selectContract(state.selectedInst, null, true); }); - if (id === "opt-profit-rr") { + if (id === "opt-target-idx") { el.addEventListener("input", updateEstimatedProfit); } }); @@ -2393,8 +2306,6 @@ window.OptionsPanelLive = { refreshSoft: function () { refreshAllPositions(); - // embed SSE 只通知「该拉了」,不推送链报价;这里节流拉新鲜卖一/买一 - softRefreshChainThrottled(false); }, refreshChain: loadChain, }; diff --git a/lib/common/static/options_position_cards.js b/lib/common/static/options_position_cards.js index 3f1e46d..d2010d2 100644 --- a/lib/common/static/options_position_cards.js +++ b/lib/common/static/options_position_cards.js @@ -219,42 +219,38 @@ const hint = closeGateHint(closePreview); return hint ? '
' + hint + "
" : ""; })() + - (p.profit_rr != null || p.target_index != null + (p.target_index != null ? (function () { - const hedgeTarget = p.hedge_plan_target || null; - const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; - const rr = - managed && hedgeTarget.oo_profit_rr != null - ? Number(hedgeTarget.oo_profit_rr) - : p.profit_rr != null - ? Number(p.profit_rr) - : null; + const eth = p.eth_amount != null ? Number(p.eth_amount) + : (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null); + const strike = Number(p.strike); + const tgt = Number(p.target_index); const prem = Number(p.premium_paid); let profit = null; - let need = null; - if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) { - profit = Math.round(prem * rr * 100) / 100; - need = Math.round((prem + profit) * 100) / 100; + let value = null; + if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) { + const o = String(p.opt_type || "").toUpperCase(); + const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null; + if (intrinsic != null) { + value = Math.round(intrinsic * eth * 100) / 100; + if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100; + } } const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC"); const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : ""; + const hedgeTarget = p.hedge_plan_target || null; + const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan"; const profitSpan = hidePnl ? "" - : '目标盈利 ' + profitTxt + ""; - const ruleTxt = - rr != null && Number.isFinite(rr) && rr > 0 - ? "盈亏比 ×" + fmt(rr, 2) - : p.target_index != null - ? "目标 " + fmt(p.target_index, 1) - : "委托中"; + : '预估盈利 ' + profitTxt + ""; return ( '
' + '' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "" + - '' + ruleTxt + "" + - (need != null ? '需回收 ' + fmtUsdc(need) + " USDC" : "") + + '目标 ' + fmt(p.target_index, 1) + "" + + '价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "" + profitSpan + '' + - (managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") + + (managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") + "
" ); })() diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index 8779dc9..d4d7e49 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -76,8 +76,6 @@ target_win_leg: "期期平盈利腿", target_up_win_leg: "期期上破·平盈利腿", target_down_win_leg: "期期下破·平盈利腿", - oo_rr_target: "期期盈亏比达标", - oo_rr_closing: "期期盈亏比平仓中", oo_rest_closing: "期期全平·清残腿中", oo_rest_closed: "期期全平·两腿已平", orphaned_after_tp: "止盈后持有至到期", diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index cbb27f2..d477ab3 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -3,7 +3,6 @@ from __future__ import annotations import json import math -import os import re import threading import time @@ -26,14 +25,6 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = { } _OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None} -# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链 -_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {} -_INSTRUMENTS_CACHE_LOCK = threading.Lock() -_INSTRUMENTS_CACHE_TTL_SEC = 90.0 -_INSTRUMENTS_STALE_SEC = 600.0 -_TICKERS_CACHE: dict[str, dict[str, Any]] = {} -_TICKERS_CACHE_LOCK = threading.Lock() -_TICKERS_CACHE_TTL_SEC = float(os.getenv("OKX_OPTIONS_TICKERS_CACHE_SEC", "10") or "10") def invalidate_options_balance_cache() -> None: @@ -41,14 +32,6 @@ def invalidate_options_balance_cache() -> None: _OPTIONS_BALANCE_CACHE["data"] = None -def invalidate_option_instruments_cache(inst_family: str | None = None) -> None: - with _INSTRUMENTS_CACHE_LOCK: - if inst_family: - _INSTRUMENTS_CACHE.pop(str(inst_family), None) - else: - _INSTRUMENTS_CACHE.clear() - - def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str: row: dict[str, Any] | None = None if isinstance(resp, dict): @@ -662,105 +645,24 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: def fetch_option_instruments( ex: ccxt.okx, inst_family: str, - *, - force: bool = False, ) -> list[dict[str, Any]]: - """拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存.""" - family = (inst_family or "").strip() - if not family: - return [] - now = time.time() - with _INSTRUMENTS_CACHE_LOCK: - cached = _INSTRUMENTS_CACHE.get(family) - if ( - not force - and cached - and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC - and isinstance(cached.get("rows"), list) - and cached["rows"] - ): - return list(cached["rows"]) - - last_err: BaseException | None = None - rows: list[dict[str, Any]] = [] - for attempt in range(4): - try: - raw = ex.public_get_public_instruments( - {"instType": "OPTION", "instFamily": family} - ).get("data") or [] - rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"] - last_err = None - break - except Exception as e: - last_err = e - if _is_okx_rate_limit(e) and attempt < 3: - time.sleep(0.8 * (2**attempt)) - continue - break - - if rows: - with _INSTRUMENTS_CACHE_LOCK: - _INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)} - return rows - - # 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」 - if cached and isinstance(cached.get("rows"), list) and cached["rows"]: - age = now - float(cached.get("updated_at") or 0) - if age < _INSTRUMENTS_STALE_SEC and ( - last_err is None or _is_okx_rate_limit(last_err) or not rows - ): - return list(cached["rows"]) - - if last_err is not None: - raise last_err - return [] + 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"] -def fetch_option_tickers( - ex: ccxt.okx, - inst_family: str, - *, - force: bool = False, -) -> dict[str, dict[str, Any]]: - family = (inst_family or "").strip() - if not family: - return {} - now = time.time() - with _TICKERS_CACHE_LOCK: - cached = _TICKERS_CACHE.get(family) - if ( - not force - and cached - and now - float(cached.get("updated_at") or 0) < max(1.0, _TICKERS_CACHE_TTL_SEC) - and isinstance(cached.get("rows"), dict) - and cached["rows"] - ): - return dict(cached["rows"]) - +def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} - last_err: BaseException | None = None - for attempt in range(3): - try: - rows = ex.public_get_market_tickers( - {"instType": "OPTION", "instFamily": family} - ).get("data") or [] - for r in rows: - if isinstance(r, dict) and r.get("instId"): - out[str(r["instId"])] = r - if out: - with _TICKERS_CACHE_LOCK: - _TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)} - return out - except Exception as e: - last_err = e - if _is_okx_rate_limit(e) and attempt < 2: - time.sleep(0.6 * (attempt + 1)) - continue - break - if cached and isinstance(cached.get("rows"), dict) and cached["rows"]: - return dict(cached["rows"]) - if last_err is not None and _is_okx_rate_limit(last_err): - return out + 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 @@ -781,17 +683,22 @@ def build_option_chain( max_ms = now_ms + max_dte_days * 86400 * 1000 instruments_err = "" instruments: list[dict[str, Any]] = [] - rate_limited = False - try: - instruments = fetch_option_instruments(ex, family) - if not instruments: + for attempt in range(2): + try: + instruments = fetch_option_instruments(ex, family) + instruments_err = "" + if instruments: + break instruments_err = "期权合约列表为空" - except Exception as e: - instruments = [] - instruments_err = str(e) or e.__class__.__name__ - rate_limited = _is_okx_rate_limit(e) - if rate_limited: - instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试" + except Exception as e: + instruments = [] + instruments_err = str(e) or e.__class__.__name__ + if attempt == 0: + time.sleep(0.35) + continue + break + if attempt == 0 and not instruments: + time.sleep(0.35) tickers = fetch_option_tickers(ex, family) expiries: dict[str, list[dict[str, Any]]] = {} skipped_no_index = 0 @@ -870,8 +777,6 @@ def build_option_chain( "expiries": exp_list, "instruments_count": len(instruments), } - if rate_limited: - out["rate_limited"] = True if not exp_list: if instruments_err: out["chain_error"] = f"拉取期权合约失败: {instruments_err}" diff --git a/lib/hedge_plan/hedge_plan_calc_lib.py b/lib/hedge_plan/hedge_plan_calc_lib.py index bd55a3e..e508c21 100644 --- a/lib/hedge_plan/hedge_plan_calc_lib.py +++ b/lib/hedge_plan/hedge_plan_calc_lib.py @@ -444,7 +444,6 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]: def build_options_options_preview( *, - profit_rr: float | None = None, target_price: float | None = None, target_price_up: float | None = None, target_price_down: float | None = None, @@ -452,11 +451,7 @@ def build_options_options_preview( leg_a: dict[str, Any], leg_b: dict[str, Any], ) -> dict[str, Any]: - """期期情景:盈亏比达标 / 到期现价 / 最大保费损耗. - - profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期. - 仍接受旧上破/下破参数仅作兼容测算. - """ + """期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗.""" def _leg_pnl(leg: dict[str, Any], spot: float) -> float: return option_expiry_pnl( @@ -468,73 +463,15 @@ def build_options_options_preview( premium_paid=float(leg.get("premium_paid") or 0), ) - prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) - a_flat = _leg_pnl(leg_a, index_px) - b_flat = _leg_pnl(leg_b, index_px) - flat_total = a_flat + b_flat - - rr = None - if profit_rr not in (None, ""): - try: - rr = float(profit_rr) - except (TypeError, ValueError): - rr = None - if rr is not None and rr > 0: - target_pnl = rr * prem - return { - "plan_type": "options_options", - "premium_paid": round(prem, 6), - "oo_profit_rr": round(rr, 4), - "target_profit": round(target_pnl, 4), - "scenarios": [ - { - "id": "rr_target", - "label": f"盈亏比×{rr:g}", - "spot": None, - "leg_a_pnl": None, - "leg_b_pnl": None, - "total": round(target_pnl, 4), - "note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期", - }, - { - "id": "expiry_flat", - "label": "到期·现价(未达标)", - "spot": index_px, - "leg_a_pnl": round(a_flat, 4), - "leg_b_pnl": round(b_flat, 4), - "total": round(flat_total, 4), - "note": "中途未达盈亏比则持有至到期结算", - }, - { - "id": "max_premium_loss", - "label": "最大保费损耗", - "spot": None, - "leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4), - "leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4), - "total": round(-prem, 4), - "note": "双腿权利金全部损失", - }, - ], - "summary": { - "oo_profit_rr": round(rr, 4), - "target_profit": round(target_pnl, 4), - "at_target_total": round(target_pnl, 4), - "expiry_flat_total": round(flat_total, 4), - "premium_paid": round(prem, 6), - "expiry_is_loss": flat_total <= 0, - "rr_risk_premium": round(prem, 6), - "rr_target": round(rr, 4), - }, - } - - # 兼容旧上破/下破测算 + # 兼容旧单目标:若未传上下目标则用 target_price 填两边 up = target_price_up if target_price_up is not None else target_price down = target_price_down if target_price_down is not None else target_price if up is None or down is None: - raise ValueError("请填写盈亏比(相对权利金,默认2)") + raise ValueError("缺少上破/下破目标价") up_f = float(up) down_f = float(down) + prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0) a_up = _leg_pnl(leg_a, up_f) b_up = _leg_pnl(leg_b, up_f) at_up = a_up + b_up @@ -545,10 +482,15 @@ def build_options_options_preview( at_dn = a_dn + b_dn win_dn = "a" if a_dn >= b_dn else "b" + a_flat = _leg_pnl(leg_a, index_px) + b_flat = _leg_pnl(leg_b, index_px) + flat_total = a_flat + b_flat + expiry_loss = flat_total if flat_total <= 0 else flat_total + return { "plan_type": "options_options", "premium_paid": round(prem, 6), - "target_price": up_f, + "target_price": up_f, # 兼容旧字段,取上破 "target_price_up": up_f, "target_price_down": down_f, "winner_at_up": win_up, @@ -596,9 +538,10 @@ def build_options_options_preview( "at_target_up_total": round(at_up, 4), "at_target_down_total": round(at_dn, 4), "at_target_total": round(at_up, 4), - "expiry_flat_total": round(flat_total, 4), + "expiry_flat_total": round(expiry_loss, 4), "premium_paid": round(prem, 6), "expiry_is_loss": flat_total <= 0, + # 盈亏比:盈利/全亏保费(风险=权利金全损) "rr_risk_premium": round(prem, 6), "rr_at_up": round(at_up / prem, 4) if prem > 0 else None, "rr_at_down": round(at_dn / prem, 4) if prem > 0 else None, diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 622dec0..299efa6 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -72,8 +72,6 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None: ) _ensure_column(conn, "hedge_plans", "target_price_up", "REAL") _ensure_column(conn, "hedge_plans", "target_price_down", "REAL") - # 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期 - _ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL") # close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状) _ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT") # 永期「以期权为主」 @@ -274,7 +272,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s rows = conn.execute( """ SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down, - p.oo_profit_rr, l.inst_id, l.opt_type + l.inst_id, l.opt_type FROM hedge_plans p JOIN hedge_plan_legs l ON l.plan_id = p.id WHERE p.plan_type = 'options_options' @@ -289,36 +287,10 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s for raw in rows: row = dict(raw) inst_id = str(row.get("inst_id") or "") - if not inst_id or inst_id in out: - continue opt_type = str(row.get("opt_type") or "").upper() - rr = _sf(row.get("oo_profit_rr")) target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down") target_f = _sf(target) - # 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示 - if rr is not None and rr > 0: - out[inst_id] = { - "plan_id": int(row["plan_id"]), - "inst_id": inst_id, - "underlying": row.get("underlying"), - "opt_type": opt_type, - "target_index": None, - "oo_profit_rr": rr, - "plan_type": "options_options", - "managed_by": "hedge_plan", - } - continue - if target_f is None or target_f <= 0: - # 无目标价也标记托管,避免期权页误拆组 - out[inst_id] = { - "plan_id": int(row["plan_id"]), - "inst_id": inst_id, - "underlying": row.get("underlying"), - "opt_type": opt_type, - "target_index": None, - "plan_type": "options_options", - "managed_by": "hedge_plan", - } + if not inst_id or target_f is None or target_f <= 0 or inst_id in out: continue out[inst_id] = { "plan_id": int(row["plan_id"]), diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index b709bc7..53addea 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -999,8 +999,6 @@ def _tick_oo_close_rest( "target_up_win_leg", "target_down_win_leg", "oo_rest_closing", - "oo_rr_closing", - "oo_rr_target", "", ) if reason0 not in allowed_reasons and not ( @@ -1051,113 +1049,7 @@ def _tick_oo_close_rest( def _tick_oo_target( cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] ) -> Optional[dict[str, Any]]: - """期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破.""" - rr = _sf(plan.get("oo_profit_rr")) - if rr is not None and rr > 0: - return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr)) - return _tick_oo_price_target(cfg, conn, plan, legs) - - -def _tick_oo_rr_target( - cfg: dict[str, Any], - conn: Any, - plan: dict[str, Any], - legs: list[dict[str, Any]], - *, - rr: float, -) -> Optional[dict[str, Any]]: - """浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期.""" - open_legs = _oo_option_legs(legs, statuses=("open",)) - if len(open_legs) < 1: - return None - premium = float(plan.get("premium_total") or 0) - if premium <= 0: - premium = sum(float(x.get("premium") or 0) for x in open_legs) - if premium <= 0: - return None - need = float(rr) * premium - quote_fn = cfg.get("quote_option_contract") - ex = cfg.get("exchange_options") - if not callable(quote_fn) or ex is None: - return None - idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) - total_pnl = 0.0 - missing_bid = 0 - for leg in open_legs: - inst = str(leg.get("inst_id") or "") - bid = None - try: - q = quote_fn(ex, inst) if inst else {} - if isinstance(q, dict) and q.get("ok"): - bid = _sf(q.get("bid")) - except Exception: - bid = None - if bid is None or float(bid) <= 0: - missing_bid += 1 - # 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过 - total_pnl += _estimate_leg_close_pnl(leg, idx, None) - else: - total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid)) - if missing_bid >= len(open_legs): - return None - if total_pnl + 1e-9 < need: - return None - - acted = False - for leg in list(open_legs): - close_r = _sell_option( - cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1) - ) - if not close_r.get("ok"): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期期盈亏比达标·平仓失败(将重试)", - plan_id=plan.get("id"), - detail=( - f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; " - f"{close_r.get('msg') or close_r}" - ), - ), - ) - update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing") - return { - "plan_id": plan["id"], - "msg": "盈亏比达标但平仓失败", - "close": close_r, - "retry": True, - "rr": rr, - "need": need, - "mtm": total_pnl, - } - bid = _sf(close_r.get("bid")) - est = _estimate_leg_close_pnl(leg, idx, bid) - pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est) - conn.execute( - "UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?", - ("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]), - ) - acted = True - - if not acted: - return None - legs2 = get_plan_legs(conn, int(plan["id"])) - still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry")) - if still_open: - update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing") - return { - "plan_id": plan["id"], - "msg": "盈亏比达标·部分已平,继续重试", - "remaining": len(still_open), - "rr": rr, - } - return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target") - - -def _tick_oo_price_target( - cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]] -) -> Optional[dict[str, Any]]: - """旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" + """期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿.""" idx = _index_px(cfg, str(plan.get("underlying") or "ETH")) if idx is None: return None diff --git a/lib/hedge_plan/hedge_plan_notify_lib.py b/lib/hedge_plan/hedge_plan_notify_lib.py index aa440ae..2e8ce7d 100644 --- a/lib/hedge_plan/hedge_plan_notify_lib.py +++ b/lib/hedge_plan/hedge_plan_notify_lib.py @@ -46,22 +46,13 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[ ] ) else: - rr = plan.get("oo_profit_rr") - if rr not in (None, ""): - lines.extend( - [ - f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) - else: - lines.extend( - [ - f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" - f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", - f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", - ] - ) + lines.extend( + [ + f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}" + f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}", + f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC", + ] + ) if legs: for leg in legs: role = leg.get("leg_role") or "" @@ -90,8 +81,6 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str: "target_win_leg": "期期已平盈利腿(中间态)", "target_up_win_leg": "期期上破·已平盈利腿", "target_down_win_leg": "期期下破·已平盈利腿", - "oo_rr_target": "期期盈亏比达标·两腿已平", - "oo_rr_closing": "期期盈亏比达标·平仓中", "oo_rest_closing": "期期全平·清残腿中", "oo_rest_closed": "期期全平·两腿已平", "oo_expiry_loss": "期期到期无盈利·总亏损", @@ -164,18 +153,7 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo "target_up_win_leg", "target_down_win_leg", "oo_rest_closing", - "oo_rr_closing", ) and (plan.get("status") or "") != "closed": - if "oo_rr" in str(plan.get("close_reason") or ""): - notify_hedge( - cfg, - build_hedge_alert_message( - title="期期盈亏比达标·平仓进行中", - plan_id=plan.get("id"), - detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金", - ), - ) - return True side = "上破" if "up" in str(plan.get("close_reason")) else ( "下破" if "down" in str(plan.get("close_reason")) else "目标价" ) diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 8e554e9..d0180d4 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1146,31 +1146,20 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: b = body.get("leg_b") or {} if not a.get("inst_id") or not b.get("inst_id"): return "请选用两条期权腿" - rr_raw = body.get("oo_profit_rr") - if rr_raw in (None, ""): - rr_raw = body.get("profit_rr") - if rr_raw not in (None, ""): - try: - rr = float(rr_raw) - except (TypeError, ValueError): - return "盈亏比无效" - if rr <= 0: - return "盈亏比须大于 0" - else: - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if up in (None, "") or down in (None, ""): - return "请填写盈亏比(相对权利金,默认2)" - try: - if float(up) <= float(down): - return "上破目标价必须大于下破目标价" - except (TypeError, ValueError): - return "目标价无效" + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + return "请填写上破与下破目标价" + try: + if float(up) <= float(down): + return "上破目标价必须大于下破目标价" + except (TypeError, ValueError): + return "目标价无效" from lib.hedge_plan.hedge_plan_moneyness_lib import ( parse_strike_from_inst, validate_oo_legs_moneyness, @@ -1190,6 +1179,11 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]: return {"opt_type": opt_type, "strike": strike} index_px = body.get("index_px") + if index_px in (None, ""): + try: + index_px = (float(up) + float(down)) / 2.0 + except (TypeError, ValueError): + index_px = None money_err = validate_oo_legs_moneyness( _leg_for_money(a), _leg_for_money(b), diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 7d32ff0..32958b3 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -537,25 +537,27 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any premium = (float(a.get("premium") or 0) if a_ok else 0.0) + ( float(b.get("premium") or 0) if b_ok else 0.0 ) - rr_raw = body.get("oo_profit_rr") - if rr_raw in (None, ""): - rr_raw = body.get("profit_rr") - try: - oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0 - except (TypeError, ValueError): - oo_rr = 2.0 - if oo_rr <= 0: - oo_rr = 2.0 plan_id = insert_plan( conn, { "plan_type": "options_options", "status": "partial" if is_partial else "active", "underlying": str(body.get("underlying") or "ETH").upper(), - "target_price": None, - "target_price_up": None, - "target_price_down": None, - "oo_profit_rr": oo_rr, + "target_price": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_up": float( + body.get("target_price_up") + or body.get("target_price") + or 0 + ), + "target_price_down": float( + body.get("target_price_down") + or body.get("target_price") + or 0 + ), "sizing_mode_at_open": load_position_sizing_mode(), "premium_total": premium, "oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")), @@ -1236,24 +1238,20 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]: def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness - rr_raw = body.get("oo_profit_rr") - if rr_raw in (None, ""): - rr_raw = body.get("profit_rr") - rr = None - if rr_raw not in (None, ""): - try: - rr = float(rr_raw) - except (TypeError, ValueError) as e: - raise ValueError("盈亏比无效") from e - if rr <= 0: - raise ValueError("盈亏比须大于 0") - - index_px = body.get("index_px") - try: - index_px_f = float(index_px) if index_px not in (None, "") else 0.0 - except (TypeError, ValueError): - index_px_f = 0.0 - + up = body.get("target_price_up") + down = body.get("target_price_down") + legacy = body.get("target_price") + if up in (None, "") and legacy not in (None, ""): + up = legacy + if down in (None, "") and legacy not in (None, ""): + down = legacy + if up in (None, "") or down in (None, ""): + raise ValueError("请填写上破与下破目标价") + up_f = float(up) + down_f = float(down) + if up_f <= down_f: + raise ValueError("上破目标价必须大于下破目标价") + index_px = float(body.get("index_px") or ((up_f + down_f) / 2)) leg_a = body.get("leg_a") or {} leg_b = body.get("leg_b") or {} for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)): @@ -1267,38 +1265,13 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: ) if leg.get("premium_paid") is None: raise ValueError(f"缺少 {name} 权利金") - money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None) + money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px) if money_err: raise ValueError(money_err) - - if rr is not None: - return build_options_options_preview( - profit_rr=rr, - index_px=index_px_f, - leg_a=leg_a, - leg_b=leg_b, - ) - - # 兼容旧上破/下破 - up = body.get("target_price_up") - down = body.get("target_price_down") - legacy = body.get("target_price") - if up in (None, "") and legacy not in (None, ""): - up = legacy - if down in (None, "") and legacy not in (None, ""): - down = legacy - if up in (None, "") or down in (None, ""): - raise ValueError("请填写盈亏比(相对权利金,默认2)") - up_f = float(up) - down_f = float(down) - if up_f <= down_f: - raise ValueError("上破目标价必须大于下破目标价") - if index_px_f <= 0: - index_px_f = (up_f + down_f) / 2 return build_options_options_preview( target_price_up=up_f, target_price_down=down_f, - index_px=index_px_f, + index_px=index_px, leg_a=leg_a, leg_b=leg_b, ) diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index c0ccb21..de47b03 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -213,7 +213,7 @@

账户:两腿都在期权账户。可用预算 = min(交易 USDC × 对冲缓冲 {{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}, 单笔预算);可在 env「对冲预算缓冲比例」改。

下单:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。

-

板块:左填盈亏比(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。两腿仅允许平值或虚值(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理。

+

板块:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。两腿仅允许平值或虚值(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。

@@ -221,7 +221,8 @@
- + + 指数 —
@@ -405,4 +406,4 @@
- + diff --git a/lib/instance/instance_dashboard_lib.py b/lib/instance/instance_dashboard_lib.py index 25a5c33..39c96c4 100644 --- a/lib/instance/instance_dashboard_lib.py +++ b/lib/instance/instance_dashboard_lib.py @@ -123,31 +123,21 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]: def _format_options_target(p: dict[str, Any]) -> str: hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() if hedge: - rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr")) - pid = hedge.get("plan_id") - if rr is not None and rr > 0: - return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}" - ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper() + ot = str(hedge.get("opt_type") or opt_type).upper() side = "Put ≤" if ot == "P" else "Call ≥" tgt = _safe_float(hedge.get("target_index")) + pid = hedge.get("plan_id") if tgt is not None: return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}" - mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None - rr = _safe_float(p.get("profit_rr")) - if rr is None and mon: - rr = _safe_float(mon.get("profit_rr")) - if rr is not None and rr > 0: - return f"盈亏比×{rr:g}" tgt = _safe_float(p.get("target_index")) - if tgt is None and mon: - tgt = _safe_float(mon.get("target_index")) if tgt is not None and tgt > 0: - opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() side = "Put ≤" if opt_type == "P" else "Call ≥" return f"{side} {tgt:g}" return "—" + def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]: inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() diff --git a/lib/options/options_dashboard_lib.py b/lib/options/options_dashboard_lib.py index 1d8820a..7c1f5c8 100644 --- a/lib/options/options_dashboard_lib.py +++ b/lib/options/options_dashboard_lib.py @@ -72,14 +72,11 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict mon = tgt_map.get(str(row.get("inst_id") or "")) if mon: row["target_index"] = mon.get("target_index") - row["profit_rr"] = mon.get("profit_rr") row["target_monitor_id"] = mon.get("id") row["target_monitor"] = mon hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) if hedge_target: row["hedge_plan_target"] = hedge_target - if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None: - row["profit_rr"] = hedge_target.get("oo_profit_rr") if not mon: row["target_index"] = hedge_target.get("target_index") rows.append(row) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 4053c8a..cbe0d9b 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -22,8 +22,7 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: raw = cfg["fetch_option_positions"](ex) if raw is None: return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"} - # 中控看板不拉逐仓 books(易超 HUB_FLASK_TIMEOUT);实例页仍走完整 preview - positions = build_display_option_positions(cfg, ex, raw, with_close_preview=False) + positions = build_display_option_positions(cfg, ex, raw) target_monitors: list[dict[str, Any]] = [] try: conn = cfg["get_db"]() @@ -39,15 +38,14 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: mon = tgt_map.get(str(p.get("inst_id") or "")) if mon: p["target_index"] = mon.get("target_index") - p["profit_rr"] = mon.get("profit_rr") p["target_monitor_id"] = mon.get("id") p["target_monitor"] = mon hedge_target = hedge_target_map.get(str(p.get("inst_id") or "")) if hedge_target: p["hedge_plan_target"] = hedge_target if not mon: + # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 p["target_index"] = hedge_target.get("target_index") - p["profit_rr"] = hedge_target.get("oo_profit_rr") try: from lib.instance.instance_dashboard_lib import ( _format_options_target, diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py index c5157cc..f77450d 100644 --- a/lib/options/options_monitor_lib.py +++ b/lib/options/options_monitor_lib.py @@ -455,7 +455,6 @@ def options_monitor_loop( conn, positions, close_fn=target_close_fn, - bid_fn=ticker_bid_fn, send_wechat=send_wechat, account_label=account_label, cfg={"send_wechat": send_wechat, "account_label": account_label}, diff --git a/lib/options/options_notify_lib.py b/lib/options/options_notify_lib.py index 6f99b92..7b32721 100644 --- a/lib/options/options_notify_lib.py +++ b/lib/options/options_notify_lib.py @@ -55,7 +55,6 @@ def build_options_open_message( premium_paid: Any = None, open_quote: Any = None, target_index: Any = None, - profit_rr: Any = None, signal_note: str = "", trade_id: Any = None, ) -> str: @@ -74,12 +73,7 @@ def build_options_open_message( f"权利金:{_fmt(premium_paid)} USDC", ] ) - if profit_rr is not None and str(profit_rr).strip() != "": - try: - lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)") - except (TypeError, ValueError): - lines.append(f"盈亏比:{profit_rr}") - elif target_index is not None and str(target_index).strip() != "": + if target_index is not None and str(target_index).strip() != "": try: lines.append(f"目标指数:{float(target_index):g}") except (TypeError, ValueError): @@ -102,7 +96,6 @@ def build_options_close_message( realized_pnl: Any = None, close_quote: Any = None, target_index: Any = None, - profit_rr: Any = None, trigger_idx: Any = None, trade_id: Any = None, ) -> str: @@ -123,12 +116,7 @@ def build_options_close_message( f"实现盈亏:{_fmt(realized_pnl, 4)} USDC", ] ) - if profit_rr is not None and str(profit_rr).strip() != "": - try: - lines.append(f"盈亏比:×{float(profit_rr):g}") - except (TypeError, ValueError): - lines.append(f"盈亏比:{profit_rr}") - elif target_index is not None and str(target_index).strip() != "": + if target_index is not None and str(target_index).strip() != "": try: lines.append(f"目标指数:{float(target_index):g}") except (TypeError, ValueError): @@ -153,7 +141,6 @@ def notify_options_open( premium_paid: Any = None, open_quote: Any = None, target_index: Any = None, - profit_rr: Any = None, signal_note: str = "", ) -> bool: ensure_options_notify_columns(conn) if conn is not None else None @@ -173,7 +160,6 @@ def notify_options_open( premium_paid=premium_paid, open_quote=open_quote, target_index=target_index, - profit_rr=profit_rr, signal_note=signal_note, trade_id=trade_id, ) @@ -210,7 +196,6 @@ def notify_options_close( realized_pnl: Any = None, close_quote: Any = None, target_index: Any = None, - profit_rr: Any = None, trigger_idx: Any = None, force: bool = False, ) -> bool: @@ -272,7 +257,6 @@ def notify_options_close( realized_pnl=total_pnl, close_quote=close_quote if close_quote is not None else head.get("close_quote"), target_index=target_index, - profit_rr=profit_rr, trigger_idx=trigger_idx, trade_id=head.get("id") if len(rows) == 1 else None, ) @@ -302,7 +286,6 @@ def notify_options_close( realized_pnl=realized_pnl, close_quote=close_quote, target_index=target_index, - profit_rr=profit_rr, trigger_idx=trigger_idx, trade_id=trade_id, ) diff --git a/lib/options/options_positions_lib.py b/lib/options/options_positions_lib.py index 2367021..a917f2c 100644 --- a/lib/options/options_positions_lib.py +++ b/lib/options/options_positions_lib.py @@ -145,10 +145,8 @@ def build_display_option_positions( cfg: dict[str, Any], ex: Any, raw_positions: list[dict[str, Any]], - *, - with_close_preview: bool = True, ) -> list[dict[str, Any]]: - """与实例 /api/options/positions 相同 enrichment;中控可关 close_preview 避免逐仓拉盘口超时.""" + """与实例 /api/options/positions 相同 enrichment + close_preview.""" meta_cache: dict[str, dict[str, Any] | None] = {} rows: list[dict[str, Any]] = [] conn = cfg["get_db"]() @@ -164,8 +162,7 @@ def build_display_option_positions( meta_cache=meta_cache, premium_override=premium_override, ) - if with_close_preview: - attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) + attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid"))) rows.append(row) finally: conn.close() diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 97865e0..acf8684 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -646,17 +646,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mode = (data.get("mode") or "budget_full").strip() signal_note = (data.get("signal_note") or "").strip() target_index = None - profit_rr = None - raw_rr = data.get("profit_rr") - if raw_rr is None or str(raw_rr).strip() == "": - raw_rr = data.get("oo_profit_rr") - if raw_rr is not None and str(raw_rr).strip() != "": - try: - profit_rr = float(raw_rr) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "盈亏比无效"}) - if profit_rr <= 0: - return jsonify({"ok": False, "msg": "盈亏比须大于 0"}) raw_target = data.get("target_index") if raw_target is not None and str(raw_target).strip() != "": try: @@ -665,9 +654,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: return jsonify({"ok": False, "msg": "目标位无效"}) if target_index <= 0: return jsonify({"ok": False, "msg": "目标位无效"}) - # 未显式传目标时默认盈亏比 2 - if profit_rr is None and target_index is None: - profit_rr = 2.0 if not inst_id: return jsonify({"ok": False, "msg": "缺少 inst_id"}) q = cfg["quote_option_contract"](ex, inst_id) @@ -834,14 +820,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: ), ) trade_id = int(cur.lastrowid) - if profit_rr is not None or target_index is not None: + if target_index is not None: from lib.options.options_target_lib import upsert_target_monitor target_mon = upsert_target_monitor( conn, inst_id=inst_id, target_index=target_index, - profit_rr=profit_rr, underlying=u, opt_type=str(opt_type) if opt_type else None, trade_id=trade_id, @@ -869,7 +854,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: premium_paid=sizing.get("total_premium"), open_quote=fill_px, target_index=target_index, - profit_rr=profit_rr, signal_note=signal_note, ) finally: @@ -985,14 +969,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: mon = tgt_map.get(inst) if mon: row["target_index"] = mon.get("target_index") - row["profit_rr"] = mon.get("profit_rr") row["target_monitor_id"] = mon.get("id") row["target_monitor"] = mon hedge_target = hedge_target_map.get(inst) if hedge_target: row["hedge_plan_target"] = hedge_target - if hedge_target.get("oo_profit_rr") is not None: - row.setdefault("profit_rr", hedge_target.get("oo_profit_rr")) try: from lib.instance.instance_dashboard_lib import _resolve_options_source @@ -1050,28 +1031,12 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn_h.close() except Exception as e: return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"}) - profit_rr = None - target_index = None - raw_rr = data.get("profit_rr") - if raw_rr is None or str(raw_rr).strip() == "": - raw_rr = data.get("oo_profit_rr") - if raw_rr is not None and str(raw_rr).strip() != "": - try: - profit_rr = float(raw_rr) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "盈亏比无效"}) - if profit_rr <= 0: - return jsonify({"ok": False, "msg": "盈亏比须大于 0"}) - raw_tgt = data.get("target_index") - if raw_tgt is not None and str(raw_tgt).strip() != "": - try: - target_index = float(raw_tgt) - except (TypeError, ValueError): - return jsonify({"ok": False, "msg": "目标位无效"}) - if target_index <= 0: - return jsonify({"ok": False, "msg": "目标位无效"}) - if profit_rr is None and target_index is None: - profit_rr = 2.0 + try: + target_index = float(data.get("target_index")) + except (TypeError, ValueError): + return jsonify({"ok": False, "msg": "目标位无效"}) + if target_index <= 0: + return jsonify({"ok": False, "msg": "目标位无效"}) raw = cfg["fetch_option_positions"](ex) if raw is None: return jsonify({"ok": False, "msg": "获取期权持仓失败"}) @@ -1100,7 +1065,6 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: conn, inst_id=inst_id, target_index=target_index, - profit_rr=profit_rr, underlying=str(underlying) if underlying else None, opt_type=str(opt_type) if opt_type else None, trade_id=trade_id, @@ -1447,24 +1411,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None: raw = cfg["fetch_option_positions"](ex) if raw is None: return [] - rows = [cfg["format_position_row"](p) for p in raw] - try: - from lib.options.options_db import sum_open_premium_paid - - conn = cfg["get_db"]() - try: - for row in rows: - inst = str(row.get("inst_id") or "") - if not inst: - continue - paid = sum_open_premium_paid(conn, inst) - if paid is not None: - row["premium_paid"] = paid - finally: - conn.close() - except Exception: - pass - return rows + return [cfg["format_position_row"](p) for p in raw] def _sync(conn): from lib.exchange.okx_options_lib import fetch_option_position_history diff --git a/lib/options/options_target_lib.py b/lib/options/options_target_lib.py index 75c8f76..ecbb8c6 100644 --- a/lib/options/options_target_lib.py +++ b/lib/options/options_target_lib.py @@ -1,14 +1,11 @@ -"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算). - -兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发. -""" +"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" from __future__ import annotations import sqlite3 import time from typing import Any, Callable -from lib.options.options_db import init_options_tables, sum_open_premium_paid +from lib.options.options_db import init_options_tables from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px @@ -21,18 +18,6 @@ def _safe_float(v: Any) -> float | None: return None -def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None: - rows = conn.execute(f"PRAGMA table_info({table})").fetchall() - names: set[str] = set() - for r in rows: - try: - names.add(str(r["name"])) - except (TypeError, KeyError, IndexError): - names.add(str(r[1])) - if col not in names: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}") - - def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]: from lib.exchange.okx_options_lib import option_fields_from_inst_id @@ -78,44 +63,21 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None: ON options_target_monitors(status) """ ) - # 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0 - _ensure_column(conn, "options_target_monitors", "profit_rr", "REAL") def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool: - """旧逻辑:Call 指数≥目标;Put 指数≤目标.""" + """Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓.""" ot = (opt_type or "").strip().upper() if ot == "P": return index_px <= target_index return index_px >= target_index -def profit_rr_hit( - *, - premium: float, - bid: float | None, - sheets: float, - ct_mult: float, - profit_rr: float, -) -> bool: - """买一回收 − 权利金 ≥ 盈亏比 × 权利金.""" - if premium <= 0 or profit_rr <= 0: - return False - if bid is None or float(bid) <= 0: - return False - if sheets <= 0 or ct_mult <= 0: - return False - recycle = float(bid) * float(sheets) * float(ct_mult) - pnl = recycle - float(premium) - return pnl + 1e-9 >= float(profit_rr) * float(premium) - - def upsert_target_monitor( conn: sqlite3.Connection, *, inst_id: str, - target_index: float | None = None, - profit_rr: float | None = None, + target_index: float, underlying: str | None = None, opt_type: str | None = None, trade_id: int | None = None, @@ -125,18 +87,9 @@ def upsert_target_monitor( inst_id = (inst_id or "").strip() if not inst_id: return {"ok": False, "msg": "缺少 inst_id"} - - rr = _safe_float(profit_rr) - tgt = _safe_float(target_index) - if rr is not None and rr > 0: - tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0 - rr_store = float(rr) - elif tgt is not None and tgt > 0: - tgt_store = float(tgt) - rr_store = None - else: - return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"} - + if target_index is None or float(target_index) <= 0: + return {"ok": False, "msg": "目标位无效"} + target_index = float(target_index) row = conn.execute( """ SELECT id FROM options_target_monitors @@ -151,7 +104,6 @@ def upsert_target_monitor( """ UPDATE options_target_monitors SET target_index = ?, - profit_rr = ?, underlying = COALESCE(?, underlying), opt_type = COALESCE(?, opt_type), trade_id = COALESCE(?, trade_id), @@ -163,13 +115,14 @@ def upsert_target_monitor( triggered_at = NULL WHERE id = ? """, - (tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])), + (target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), ) mon_id = int(row["id"]) + # 同一合约其他进行中的委托取消,避免双轨触发重复推送 conn.execute( """ UPDATE options_target_monitors - SET status = 'cancelled', message = '被新目标委托覆盖' + SET status = 'cancelled', message = '被新目标位覆盖' WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing') """, (inst_id, mon_id), @@ -178,21 +131,13 @@ def upsert_target_monitor( cur = conn.execute( """ INSERT INTO options_target_monitors - (inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'active') + (inst_id, underlying, opt_type, target_index, trade_id, sheets, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') """, - (inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets), + (inst_id, underlying, opt_type, target_index, trade_id, sheets), ) mon_id = int(cur.lastrowid) - out: dict[str, Any] = { - "ok": True, - "id": mon_id, - "inst_id": inst_id, - "target_index": tgt_store if tgt_store > 0 else None, - } - if rr_store is not None: - out["profit_rr"] = rr_store - return out + return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index} def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int: @@ -221,19 +166,12 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: - tgt = _safe_float(r["target_index"]) - rr = None - try: - rr = _safe_float(r["profit_rr"]) - except (KeyError, IndexError): - rr = None return { "id": int(r["id"]), "inst_id": r["inst_id"], "underlying": r["underlying"], "opt_type": r["opt_type"], - "target_index": tgt if tgt is not None and tgt > 0 else None, - "profit_rr": rr if rr is not None and rr > 0 else None, + "target_index": _safe_float(r["target_index"]), "trade_id": r["trade_id"], "sheets": r["sheets"], "status": r["status"], @@ -242,16 +180,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: } -_TARGET_SELECT = ( - "SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, " - "status, message, created_at FROM options_target_monitors" -) - - def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: ensure_target_tables(conn) rows = conn.execute( - f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC" + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'active' + ORDER BY id DESC + """ ).fetchall() return [_row_to_target(r) for r in rows] @@ -260,7 +198,13 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: """已挂出平仓单、等待成交的目标(不再重复推送微信).""" ensure_target_tables(conn) rows = conn.execute( - f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC" + """ + SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, + status, message, created_at + FROM options_target_monitors + WHERE status = 'closing' + ORDER BY id DESC + """ ).fetchall() return [_row_to_target(r) for r in rows] @@ -342,23 +286,23 @@ def close_option_by_bid_depth( inst_id, sheets=sheets, require_recycle_gate=True, - signal_note="盈亏比平仓", + signal_note="目标位平仓", ) + def _notify_target_close( cfg: dict[str, Any] | None, send_wechat: Callable[[str], None] | None, *, account_label: str, inst_id: str, - target: float | None, - profit_rr: float | None, - idx: float | None, + target: float, + idx: float, result: dict[str, Any], conn: Any = None, ) -> None: - """目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" + """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" if result.get("fully_closed") or result.get("already_flat"): if cfg is not None: try: @@ -368,13 +312,12 @@ def _notify_target_close( cfg, conn, inst_id=inst_id, - reason="盈亏比平仓" if profit_rr else "目标位平仓", + reason="目标位平仓", sheets=result.get("submitted_sheets"), premium_received=result.get("premium_received"), close_quote=result.get("locked_bid_px") or result.get("bid"), target_index=target, trigger_idx=idx, - profit_rr=profit_rr, ) return except Exception: @@ -382,20 +325,14 @@ def _notify_target_close( if not send_wechat: return try: - if profit_rr is not None and profit_rr > 0: - rule = f"盈亏比×{profit_rr:g}" - elif target is not None: - rule = f"目标指数:{target:g}" - else: - rule = "目标委托" send_wechat( "\n".join( [ - "【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】", + "【OKX期权·目标位平仓】", f"账户:{account_label}", f"合约:{inst_id}", - rule, - f"触发指数:{idx:g}" if idx is not None else "触发指数:—", + f"目标指数:{target:g}", + f"触发指数:{idx:g}", f"提交张数:{result.get('submitted_sheets') or '—'}", f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", @@ -417,77 +354,18 @@ def _result_fully_done(result: dict[str, Any]) -> bool: return False -def _monitor_should_close( - conn: sqlite3.Connection, - mon: dict[str, Any], - pos: dict[str, Any], - *, - bid_fn: Callable[[str], float | None] | None, - index_fn: Callable[[dict[str, Any]], float | None] | None, -) -> tuple[bool, float | None]: - """返回 (是否触发, 当前指数).""" - inst_id = str(mon.get("inst_id") or "") - rr = _safe_float(mon.get("profit_rr")) - if index_fn is not None: - idx = index_fn(pos) - else: - idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) - - if rr is not None and rr > 0: - premium = sum_open_premium_paid(conn, inst_id) - if premium is None or premium <= 0: - premium = _safe_float(pos.get("premium_paid")) - sheets = _safe_float(mon.get("sheets")) - if sheets is None or sheets <= 0: - sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos")) - ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01 - bid = None - if bid_fn is not None: - try: - bid = bid_fn(inst_id) - except Exception: - bid = None - if bid is None: - bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid")) - preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {} - if bid is None: - bid = _safe_float(preview.get("bid") or preview.get("best_bid")) - if premium is None or sheets is None: - return False, idx - return ( - profit_rr_hit( - premium=float(premium), - bid=bid, - sheets=float(sheets), - ct_mult=float(ct), - profit_rr=float(rr), - ), - idx, - ) - - target = _safe_float(mon.get("target_index")) - if target is None or target <= 0 or idx is None: - return False, idx - opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") - return ( - target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target), - idx, - ) - - def run_options_target_closes( conn: sqlite3.Connection, positions: list[dict[str, Any]], *, close_fn: Callable[[str], dict[str, Any]], index_fn: Callable[[dict[str, Any]], float | None] | None = None, - bid_fn: Callable[[str], float | None] | None = None, send_wechat: Callable[[str], None] | None = None, account_label: str = "OKX期权", cfg: dict[str, Any] | None = None, ) -> int: """ - 扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓. + 扫描 active 目标委托;指数到位后限价平仓. 状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送. 未完全成交进入 closing,仅重试平仓不再推送. 返回本次新触发(并推送)的条数. @@ -534,7 +412,7 @@ def run_options_target_closes( status="triggered", trigger_idx=idx, close_ord_id=result.get("close_ord_id"), - message="盈亏比限价平仓完成", + message="目标位限价平仓完成", ) _commit_monitor(conn) continue @@ -551,7 +429,8 @@ def run_options_target_closes( triggered = 0 for mon in list_active_targets(conn): inst_id = str(mon.get("inst_id") or "") - if not inst_id: + target = _safe_float(mon.get("target_index")) + if not inst_id or target is None: continue if inst_id in hedge_managed: mark_monitor( @@ -565,15 +444,17 @@ def run_options_target_closes( pos = pos_by_inst.get(inst_id) if not pos: continue - should, idx = _monitor_should_close( - conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn - ) - if not should: + if index_fn is not None: + idx = index_fn(pos) + else: + idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) + if idx is None: + continue + opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") + if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): continue result = close_fn(inst_id) - rr = _safe_float(mon.get("profit_rr")) - target = _safe_float(mon.get("target_index")) if result.get("already_flat"): mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") _commit_monitor(conn) @@ -591,19 +472,15 @@ def run_options_target_closes( done = _result_fully_done(result) status = "triggered" if done else "closing" - hit_msg = ( - "盈亏比达标限价平仓" - if (rr is not None and rr > 0) - else "目标位触发限价平仓" - ) mark_monitor( conn, int(mon["id"]), status=status, trigger_idx=idx, close_ord_id=result.get("close_ord_id"), - message=hit_msg if done else "已挂买一限价,等待成交", + message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交", ) + # 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信 _commit_monitor(conn) triggered += 1 _notify_target_close( @@ -612,7 +489,6 @@ def run_options_target_closes( account_label=account_label, inst_id=inst_id, target=target, - profit_rr=rr, idx=idx, result=result, conn=conn, diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index 9fe468f..c9546ca 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -107,15 +107,17 @@
- - 目标位(指数) + - 目标盈利 + 预计价值 + + 盈利 - 需回收 - + 目标杠杆 +
- 按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损 + 目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损
@@ -322,4 +324,4 @@
- + diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index afd0aa3..f67311b 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -416,4 +416,4 @@ - + diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 25bc494..46cbe6f 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -2028,8 +2028,7 @@ async def _fetch_flask_json( return parsed return _parse_http_json_body(r) except Exception as e: - err = str(e) - return {"ok": False, "error": err, "msg": err} + return {"ok": False, "error": str(e)} async def _notify_instance_user_close( @@ -2568,8 +2567,8 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict async def _fetch_exchange_flask_bundle( client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None -) -> tuple: - """单所 Flask:monitor / meta / price_snapshot / account / trades/today / options 并行拉取.""" +) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]: + """单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取.""" caps = ex.get("capabilities") or [] tasks = [ _fetch_flask_json(client, ex, "/api/hub/monitor"), @@ -2577,7 +2576,6 @@ async def _fetch_exchange_flask_bundle( ] has_flask = bool((ex.get("flask_url") or "").strip()) day = (trading_day or "").strip() - want_options = has_flask and "options" in caps if has_flask: tasks.extend( [ @@ -2594,26 +2592,15 @@ async def _fetch_exchange_flask_bundle( params={"trading_day": day}, ) ) - if want_options: - tasks.append(_fetch_flask_json(client, ex, "/api/hub/options/snapshot")) results = await asyncio.gather(*tasks) hub_mon = results[0] meta = results[1] - idx = 2 - snap = None - account = None - trades_today = None + snap = results[2] if has_flask and len(results) > 2 else None + account = results[3] if has_flask and len(results) > 3 else None + trades_today = results[4] if has_flask and day and len(results) > 4 else None options_snap = None - if has_flask: - snap = results[idx] - idx += 1 - account = results[idx] - idx += 1 - if day: - trades_today = results[idx] - idx += 1 - if want_options: - options_snap = results[idx] + if has_flask and "options" in caps: + options_snap = await _fetch_flask_json(client, ex, "/api/hub/options/snapshot") key_prices = None want_prices = HUB_BOARD_KEY_PRICES and "key" in caps if want_prices and isinstance(snap, dict): diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 0a892e5..450dfba 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3930,19 +3930,6 @@ function renderOptionsTargetCell(target) { if (!target) return "—"; - const rr = - target.profit_rr != null - ? Number(target.profit_rr) - : target.oo_profit_rr != null - ? Number(target.oo_profit_rr) - : null; - if (rr != null && Number.isFinite(rr) && rr > 0) { - const txt = `盈亏比×${fmt(rr, 2)}`; - if (target.managed_by === "hedge_plan") { - return `对冲#${esc(target.plan_id)} ${esc(txt)}`; - } - return `${esc(txt)}`; - } const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥"; const px = target.target_index != null ? fmt(target.target_index, 1) : "—"; if (target.managed_by === "hedge_plan") { @@ -3955,7 +3942,7 @@ if (!pos.length) return '
暂无期权持仓
'; const showPnl = showAccountPnlPref(); let html = '
'; - html += ""; + html += ""; if (showPnl) html += ""; html += ""; pos.forEach((p) => { @@ -4022,26 +4009,20 @@ function renderOptionsSectionBody(row, opts) { const options = opts || {}; const layout = options.layout || "table"; - const caps = Array.isArray(row.capabilities) ? row.capabilities : []; - const wantsOptions = caps.indexOf("options") >= 0; - const opt = row.options; + const opt = row.options || {}; let html = ""; - if (wantsOptions && (opt == null || typeof opt !== "object")) { - html += '
期权持仓
'; - html += `
期权数据不可用
`; - } else if ((opt || {}).enabled === false) { - html += renderOptionsAccountStatRow(opt || {}); + if (opt.enabled === false) { + html += renderOptionsAccountStatRow(opt); html += '
期权持仓
'; html += '
期权未启用(OKX_OPTIONS_ENABLED)
'; - } else if ((opt || {}).ok === false) { - html += renderOptionsAccountStatRow(opt || {}); + } else if (opt.ok === false) { + html += renderOptionsAccountStatRow(opt); html += '
期权持仓
'; - html += `
${esc((opt && (opt.msg || opt.error)) || "期权数据不可用")}
`; + html += `
${esc(opt.msg || "期权数据不可用")}
`; } else { - const optSafe = opt || {}; - const pos = Array.isArray(optSafe.positions) ? optSafe.positions : []; - const targets = Array.isArray(optSafe.target_monitors) ? optSafe.target_monitors : []; - html += renderOptionsAccountStatRow(optSafe); + const pos = Array.isArray(opt.positions) ? opt.positions : []; + const targets = Array.isArray(opt.target_monitors) ? opt.target_monitors : []; + html += renderOptionsAccountStatRow(opt); html += `
期权持仓 · ${pos.length} 仓
`; html += layout === "cards" diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f6e9c16..2e5808b 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -115,7 +115,7 @@
合约类型张数到期倒计时盈亏比合约类型张数到期倒计时目标监控净盈亏收益率