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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-07 12:55:35 +08:00
parent 11a50c02cd
commit 6cc3382526
10 changed files with 299 additions and 6 deletions
+1
View File
@@ -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,
}
+15
View File
@@ -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():
+83
View File
@@ -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)}
+12
View File
@@ -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,
+7 -2
View File
@@ -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,
}
+6 -1
View File
@@ -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
+63
View File
@@ -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;
}
+74 -1
View File
@@ -3414,6 +3414,62 @@
</div>`;
}
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 += '<div class="section-title hub-options-title">期权 · 主账户</div>';
html += '<div class="empty-hint">期权未启用(OKX_OPTIONS_ENABLED</div>';
return html;
}
if (opt.ok === false) {
html += '<div class="section-title hub-options-title">期权 · 主账户</div>';
html += `<div class="err">${esc(opt.msg || "期权数据不可用")}</div>`;
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 += `<div class="section-title hub-options-title">期权 · 主账户 · ${pos.length} 仓</div>`;
html += `<div class="hub-options-summary">
<span>交易户 <strong>${fmt(tradingUsdc, 2)}</strong> USDC</span>
<span>浮盈 <strong class="${pnlCls(upl)}">${fmt(upl, 4)}</strong> USDC</span>
<span>已平 ${esc(stats.total_closed != null ? stats.total_closed : "—")} · 胜率 ${esc(stats.win_rate != null ? stats.win_rate + "%" : "—")}</span>
</div>`;
if (pos.length) {
html += '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
html += "<th>合约</th><th>类型</th><th>张数</th><th>标记</th><th>浮盈</th><th>浮盈%</th>";
html += "</tr></thead><tbody>";
pos.forEach((p) => {
const optType = (p.opt_type || "").toUpperCase() === "C" ? "Call" : (p.opt_type || "").toUpperCase() === "P" ? "Put" : (p.opt_type || "—");
html += `<tr>
<td><code class="hub-options-inst" title="${esc(p.inst_id || "")}">${esc(shortOptionsInst(p.inst_id))}</code></td>
<td>${esc(optType)}</td>
<td>${esc(p.pos)}</td>
<td>${fmt(p.mark_px, 4)}</td>
<td class="${pnlCls(p.upl)}">${fmt(p.upl, 4)}</td>
<td class="${pnlCls(p.upl)}">${p.upl_ratio_pct != null ? esc(p.upl_ratio_pct) + "%" : "—"}</td>
</tr>`;
});
html += "</tbody></table></div>";
} else {
html += '<div class="empty-hint">暂无期权持仓</div>';
}
if (row.flask_url_browser || row.flask_url) {
html += `<div class="hub-options-actions"><a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">打开期权页</a></div>`;
}
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 += '<div class="empty-hint">无持仓</div>';
}
inner += renderOptionsMonitorSection(row);
inner += renderCardStrategyStats(row, hm, flaskOk);
inner += intraday
? `<div class="card-expand-hint">日内纪律:禁手动平仓/改委托 · 整点强制清仓${row.force_close && row.force_close.enabled ? " · " + esc(row.force_close.label || "强制清仓") : ""}</div>`
@@ -3467,6 +3524,7 @@
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/trade">下单</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/key_monitor">监控位</a>` : ""}
${flaskOpen ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>` : ""}
${flaskOpen && (row.capabilities || []).includes("options") ? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>` : ""}
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
</div>
</div>`;
@@ -3495,6 +3553,7 @@
html += '<div class="pos-empty">暂无持仓</div>';
}
html += "</div>";
html += renderOptionsMonitorSection(row);
html += '<div class="hub-fs-sections-grid">';
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 @@
? `<div class="hub-tile-pnl ${pnlCls(upnl)}">${fmt(upnl, 2)} <small>U</small></div>`
: ""
}
<div class="hub-tile-meta">${esc(posLine)}</div>
<div class="hub-tile-meta">${esc(posLineWithOpt)}</div>
${strategyStats}
<div class="hub-tile-foot">UPD ${esc(tsShort)}</div>
</div>
@@ -3801,6 +3867,10 @@
const openReview = flaskOpen
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/records">复盘</a>`
: "";
const openOptions =
flaskOpen && (row.capabilities || []).includes("options")
? `<a class="btn-link btn-open-instance" href="#" data-ex-id="${esc(row.id)}" data-next="/options">期权</a>`
: "";
const intraday = isIntradayDisciplineRow(row);
const fcHeadBadge = intraday ? forceCloseHeadBadgeHtml(row.force_close) : "";
return `<div class="card ${cardCls}" data-ex-id="${esc(row.id)}">
@@ -3816,6 +3886,7 @@
${openFlask}
${openTrade}
${openKey}
${openOptions}
${openReview}
${intraday ? "" : `<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>`}
</div>
@@ -4253,6 +4324,7 @@
<div class="cap-chips">
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
<label><input type="checkbox" class="cap-options" ${caps.includes("options") ? "checked" : ""}/> 监控期权</label>
</div>
<div class="settings-card-foot">
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
@@ -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 {
+2 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260706-strategy-scroll" />
<link rel="stylesheet" href="/assets/app.css?v=20260707-hub-options" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=3" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
@@ -1151,6 +1151,6 @@
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=3"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260704-monitor-stats-v2"></script>
<script src="/assets/app.js?v=20260707-hub-options"></script>
</body>
</html>
+36
View File
@@ -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)