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 += '