From 6cc3382526faaae574564589b3610e903ff8f9bb Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 7 Jul 2026 12:55:35 +0800 Subject: [PATCH] Hub monitor: read-only OKX options positions aggregation (Phase B). Expose /api/hub/options/snapshot from OKX instance and render options summary on hub monitor cards when the options capability is enabled. Co-authored-by: Cursor --- crypto_monitor_okx/app.py | 1 + lib/hub/hub_bridge.py | 15 +++++ lib/options/options_hub_lib.py | 83 ++++++++++++++++++++++++++++ lib/options/options_register.py | 12 ++++ manual_trading_hub/hub.py | 9 ++- manual_trading_hub/settings_store.py | 7 ++- manual_trading_hub/static/app.css | 63 +++++++++++++++++++++ manual_trading_hub/static/app.js | 75 ++++++++++++++++++++++++- manual_trading_hub/static/index.html | 4 +- tests/test_options_hub_lib.py | 36 ++++++++++++ 10 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 lib/options/options_hub_lib.py create mode 100644 tests/test_options_hub_lib.py diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 426fea2..c9e43d3 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -9287,6 +9287,7 @@ def _hub_meta_bundle(): "alt_leverage": ALT_LEVERAGE, "trade_policy": trade_policy_template_context(TRADE_POLICY), **hub_meta_entry_context(TRADE_POLICY), + "options_enabled": OKX_OPTIONS_ENABLED, } diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 6baa68f..a8b9d20 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -454,6 +454,21 @@ def register_hub_routes(app): except Exception as e: return jsonify({"ok": False, "msg": str(e)}), 500 + @app.route("/api/hub/options/snapshot") + @_hub_auth_required + def api_hub_options_snapshot(): + """中控监控:期权持仓 / 资金 / 本地统计(只读)。""" + fn = _ctx().get("options_snapshot_fn") + if not callable(fn): + return jsonify({"ok": True, "enabled": False}) + try: + data = fn() + if not isinstance(data, dict): + data = {"ok": False, "enabled": True, "msg": "invalid snapshot"} + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "enabled": True, "msg": str(e)}), 500 + @app.route("/api/account_risk_status") @_hub_auth_required def api_account_risk_status(): diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py new file mode 100644 index 0000000..c40d051 --- /dev/null +++ b/lib/options/options_hub_lib.py @@ -0,0 +1,83 @@ +"""中控只读聚合:OKX 期权持仓 / 资金 / 本地统计。""" + +from __future__ import annotations + +from typing import Any + +from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages +from lib.options.options_db import init_options_tables + + +def _compute_options_stats(get_db) -> dict[str, Any]: + conn = get_db() + try: + init_options_tables(conn) + rows = conn.execute( + """ + SELECT realized_pnl FROM options_trades + WHERE status = 'closed' AND realized_pnl IS NOT NULL + """ + ).fetchall() + finally: + conn.close() + wins: list[float] = [] + losses: list[float] = [] + for row in rows: + pnl = float(row["realized_pnl"]) + if pnl > 0: + wins.append(pnl) + elif pnl < 0: + losses.append(pnl) + total_closed = len(wins) + len(losses) + win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0 + avg_win = sum(wins) / len(wins) if wins else None + avg_loss = sum(losses) / len(losses) if losses else None + return { + "total_closed": total_closed, + "win_rate": win_rate, + "profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss), + "total_profit": round(sum(wins), 4) if wins else 0.0, + "total_loss": round(abs(sum(losses)), 4) if losses else 0.0, + } + + +def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: + if not cfg.get("enabled"): + return {"ok": True, "enabled": False} + ex = cfg.get("exchange_options") + ready_fn = cfg.get("options_api_ready") + if not callable(ready_fn): + return {"ok": False, "enabled": True, "msg": "期权模块未就绪"} + ok, reason = ready_fn(ex) + if not ok: + return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"} + try: + raw = cfg["fetch_option_positions"](ex) + positions = [cfg["format_position_row"](p) for p in raw] + upl_total = 0.0 + has_upl = False + for p in positions: + upl = p.get("upl") + if upl is None: + continue + has_upl = True + upl_total += float(upl) + bal = cfg["fetch_options_balances"](ex) + stats = _compute_options_stats(cfg["get_db"]) + return { + "ok": True, + "enabled": True, + "positions": positions, + "position_count": len(positions), + "upl_total_usdc": round(upl_total, 4) if has_upl else None, + "balances": bal, + "funding_usdc": bal.get("funding_usdc"), + "funding_usdt": bal.get("funding_usdt"), + "trading_usdc": bal.get("trading_usdc"), + "trading_usdt": bal.get("trading_usdt"), + "stats": stats, + "trade_budget": cfg.get("trade_budget"), + "account_label": cfg.get("account_label") or "OKX期权", + } + except Exception as e: + return {"ok": False, "enabled": True, "msg": str(e)} diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 3a658a2..45903e3 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -54,10 +54,22 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None cfg = _build_cfg(app_module) app.extensions["options_cfg"] = cfg register_options_routes(app, cfg) + _register_options_hub_bridge(app, cfg) if enabled: _start_monitor_thread(app, cfg) +def _register_options_hub_bridge(app: Flask, cfg: dict[str, Any]) -> None: + from lib.options.options_hub_lib import build_options_hub_snapshot + + def snapshot_fn(): + return build_options_hub_snapshot(cfg) + + hub_ctx = dict(app.config.get("HUB_CTX") or {}) + hub_ctx["options_snapshot_fn"] = snapshot_fn + app.config["HUB_CTX"] = hub_ctx + + def _build_cfg(app_module: Any) -> dict[str, Any]: from lib.exchange.okx_options_lib import ( build_option_chain, diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 31b5182..303c4c5 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1627,7 +1627,7 @@ def api_settings_meta(): return { "env_disabled_ids": sorted(env_force_disabled_ids()), "hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN), - "capability_options": ["key", "trend"], + "capability_options": ["key", "trend", "options"], "public_origin": f"{po[0]}://{po[1]}" if po else None, "public_origin_hint": ( "未设置 HUB_PUBLIC_ORIGIN 时,复盘链接若为 127.0.0.1,仅服务器本机浏览器可打开" @@ -2306,6 +2306,9 @@ async def _fetch_exchange_flask_bundle( 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 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): @@ -2317,6 +2320,7 @@ async def _fetch_exchange_flask_bundle( snap if isinstance(snap, dict) else None, account if isinstance(account, dict) else None, trades_today if isinstance(trades_today, dict) else None, + options_snap if isinstance(options_snap, dict) else None, ) @@ -2342,7 +2346,7 @@ def _day_stats_from_trades_body(body: dict | None) -> dict: async def _assemble_board_row( client: httpx.AsyncClient, ex: dict, agent_row: dict, *, trading_day: str ) -> dict: - hub_mon, meta, key_prices, snap, account, trades_today = await _fetch_exchange_flask_bundle( + hub_mon, meta, key_prices, snap, account, trades_today, options_snap = await _fetch_exchange_flask_bundle( client, ex, trading_day=trading_day ) if isinstance(hub_mon, dict): @@ -2372,6 +2376,7 @@ async def _assemble_board_row( "account_ok": acct_ok, "day_stats": _day_stats_from_trades_body(trades_today), "force_close": snap.get("force_close") if isinstance(snap, dict) else None, + "options": options_snap, } diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 70bf212..a699985 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -50,7 +50,7 @@ DEFAULT_EXCHANGES = [ "flask_url": "http://127.0.0.1:5004", "review_url": "http://127.0.0.1:5004/records", "enabled": True, - "capabilities": ["key", "trend"], + "capabilities": ["key", "trend", "options"], }, { "id": "2", @@ -109,6 +109,11 @@ def load_settings() -> dict: ex["env_disabled"] = True else: ex.setdefault("env_disabled", False) + if ex.get("key") == "okx": + caps = list(ex.get("capabilities") or []) + if "options" not in caps: + caps.append("options") + ex["capabilities"] = caps return data diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index ce3eea4..f4c541e 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -7896,3 +7896,66 @@ body.funds-fullscreen-open { } } +/* ── 监控页:OKX 期权只读聚合 ── */ +.hub-options-title { + margin-top: 14px; +} + +.hub-options-summary { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + font-size: 0.78rem; + color: var(--muted); + margin: 0 0 8px; +} + +.hub-options-summary strong { + color: var(--text); + font-weight: 600; +} + +.hub-options-table-wrap { + margin-bottom: 8px; +} + +.hub-options-table { + width: 100%; + border-collapse: collapse; + font-size: 0.76rem; +} + +.hub-options-table th, +.hub-options-table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.hub-options-table th { + color: var(--muted); + font-weight: 600; + font-size: 0.72rem; +} + +.hub-options-inst { + font-size: 0.7rem; + word-break: break-all; +} + +.hub-options-actions { + margin-top: 6px; +} + +html[data-theme="light"] .hub-options-table th { + color: #3a5068; +} + +html[data-theme="light"] .hub-options-summary { + color: #3a5068; +} + +html[data-theme="light"] .hub-options-summary strong { + color: #142232; +} + diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index de33960..114556f 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3414,6 +3414,62 @@ `; } + function shortOptionsInst(instId) { + const s = String(instId || ""); + if (s.length <= 22) return s; + return s.slice(0, 10) + "…" + s.slice(-8); + } + + function renderOptionsMonitorSection(row) { + const caps = row.capabilities || []; + if (!caps.includes("options")) return ""; + const opt = row.options || {}; + let html = ""; + if (opt.enabled === false) { + html += '
期权 · 主账户
'; + html += '
期权未启用(OKX_OPTIONS_ENABLED)
'; + return html; + } + if (opt.ok === false) { + html += '
期权 · 主账户
'; + html += `
${esc(opt.msg || "期权数据不可用")}
`; + return html; + } + const pos = Array.isArray(opt.positions) ? opt.positions : []; + const upl = opt.upl_total_usdc; + const tradingUsdc = opt.trading_usdc != null ? opt.trading_usdc : (opt.balances && opt.balances.trading_usdc); + const stats = opt.stats || {}; + html += `
期权 · 主账户 · ${pos.length} 仓
`; + html += `
+ 交易户 ${fmt(tradingUsdc, 2)} USDC + 浮盈 ${fmt(upl, 4)} USDC + 已平 ${esc(stats.total_closed != null ? stats.total_closed : "—")} · 胜率 ${esc(stats.win_rate != null ? stats.win_rate + "%" : "—")} +
`; + if (pos.length) { + html += '
'; + html += ""; + html += ""; + pos.forEach((p) => { + const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—"); + html += ` + + + + + + + `; + }); + html += "
合约类型张数标记浮盈浮盈%
${esc(shortOptionsInst(p.inst_id))}${esc(optType)}${esc(p.pos)}${fmt(p.mark_px, 4)}${fmt(p.upl, 4)}${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}
"; + } else { + html += '
暂无期权持仓
'; + } + if (row.flask_url_browser || row.flask_url) { + html += ``; + } + return html; + } + function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) { const tickMap = buildPriceTickMap(row); const intraday = isIntradayDisciplineRow(row); @@ -3432,6 +3488,7 @@ } else { inner += '
无持仓
'; } + inner += renderOptionsMonitorSection(row); inner += renderCardStrategyStats(row, hm, flaskOk); inner += intraday ? `
日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}
` @@ -3467,6 +3524,7 @@ ${flaskOpen ? `下单` : ""} ${flaskOpen ? `监控位` : ""} ${flaskOpen ? `复盘` : ""} + ${flaskOpen && (row.capabilities || []).includes("options") ? `期权` : ""} ${intraday ? "" : ``} `; @@ -3495,6 +3553,7 @@ html += '
暂无持仓
'; } html += ""; + html += renderOptionsMonitorSection(row); html += '
'; if ((row.capabilities || []).includes("key")) { if (!flaskOk) { @@ -3739,6 +3798,13 @@ const tsShort = ts ? ts.slice(-8) : "—"; const posLine = openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary; + const opt = row.options || {}; + const optCount = + opt.enabled !== false && opt.ok !== false + ? Number(opt.position_count != null ? opt.position_count : (opt.positions || []).length) + : 0; + const posLineWithOpt = + optCount > 0 ? `${posLine} · 期权${optCount}仓` : posLine; const hm = row.hub_monitor || {}; const flaskOk = row.flask_ok !== false && hm.ok !== false; const strategyStats = renderCardStrategyStats(row, hm, flaskOk); @@ -3754,7 +3820,7 @@ ? `
${fmt(upnl, 2)} U
` : "" } -
${esc(posLine)}
+
${esc(posLineWithOpt)}
${strategyStats}
UPD ${esc(tsShort)}
@@ -3801,6 +3867,10 @@ const openReview = flaskOpen ? `复盘` : ""; + const openOptions = + flaskOpen && (row.capabilities || []).includes("options") + ? `期权` + : ""; const intraday = isIntradayDisciplineRow(row); const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : ""; return `
@@ -3816,6 +3886,7 @@ ${openFlask} ${openTrade} ${openKey} + ${openOptions} ${openReview} ${intraday ? "" : ``}
@@ -4253,6 +4324,7 @@
+
@@ -4308,6 +4380,7 @@ const caps = []; if (card.querySelector(".cap-key").checked) caps.push("key"); if (card.querySelector(".cap-trend").checked) caps.push("trend"); + if (card.querySelector(".cap-options") && card.querySelector(".cap-options").checked) caps.push("options"); const id = card.querySelector(".ex-id").value.trim(); const stableKey = (card.dataset.key || id).trim(); return { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 7cad8a9..f8efcfa 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -1151,6 +1151,6 @@ - + diff --git a/tests/test_options_hub_lib.py b/tests/test_options_hub_lib.py new file mode 100644 index 0000000..9ada730 --- /dev/null +++ b/tests/test_options_hub_lib.py @@ -0,0 +1,36 @@ +from unittest import TestCase +from unittest.mock import MagicMock + +from lib.options.options_hub_lib import build_options_hub_snapshot + + +class OptionsHubLibTests(TestCase): + def test_build_options_hub_snapshot_disabled(self): + out = build_options_hub_snapshot({"enabled": False}) + self.assertFalse(out["enabled"]) + self.assertTrue(out["ok"]) + + def test_build_options_hub_snapshot_positions(self): + cfg = { + "enabled": True, + "exchange_options": object(), + "options_api_ready": lambda ex: (True, ""), + "fetch_option_positions": lambda ex: [ + {"instId": "ETH-USD_UM-260703-1800-C", "pos": "2", "upl": "1.5", "markPx": "0.1"} + ], + "format_position_row": lambda p: { + "inst_id": p.get("instId"), + "pos": 2, + "upl": 1.5, + "mark_px": 0.1, + }, + "fetch_options_balances": lambda ex: {"trading_usdc": 9.5, "funding_usdc": 12.0}, + "get_db": MagicMock(), + "trade_budget": 10, + "account_label": "OKX期权", + } + out = build_options_hub_snapshot(cfg) + self.assertTrue(out["ok"]) + self.assertEqual(out["position_count"], 1) + self.assertEqual(out["upl_total_usdc"], 1.5) + self.assertEqual(out["trading_usdc"], 9.5)