diff --git a/lib/hub/hub_fund_history_lib.py b/lib/hub/hub_fund_history_lib.py index 78cb95f..7bcf20d 100644 --- a/lib/hub/hub_fund_history_lib.py +++ b/lib/hub/hub_fund_history_lib.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Optional from lib.hub.hub_trades_lib import current_trading_day +from lib.hub.hub_options_funds_lib import merge_board_row_balances from lib.paths import manual_trading_hub_dir @@ -160,13 +161,20 @@ def record_fund_snapshot( total = account_total_usdt(fu, tu) if total is None: continue - row_accounts[key] = { + entry: dict[str, Any] = { "name": ac.get("name"), "funding_usdt": fu, "trading_usdt": tu, "total_usdt": total, "recorded_at": _now_str(), } + ofu = _safe_float(ac.get("options_funding_usdt")) + otu = _safe_float(ac.get("options_trading_usdt")) + if ofu is not None: + entry["options_funding_usdt"] = ofu + if otu is not None: + entry["options_trading_usdt"] = otu + row_accounts[key] = entry if row_accounts: days[day] = {"accounts": row_accounts, "updated_at": _now_str()} days = _prune_days( @@ -188,14 +196,23 @@ def record_fund_snapshot_from_board( for row in rows or []: if not isinstance(row, dict): continue - if not row.get("account_ok"): + if not row.get("account_ok") and not ( + "options" in (row.get("capabilities") or []) + and isinstance(row.get("options"), dict) + and row.get("options", {}).get("ok") + ): + continue + merged = merge_board_row_balances(row) + if not merged.get("data_ok"): continue accounts.append( { "key": row.get("key") or row.get("id"), "name": row.get("name"), - "funding_usdt": row.get("funding_usdt"), - "trading_usdt": row.get("trading_usdt"), + "funding_usdt": merged.get("funding_usdt"), + "trading_usdt": merged.get("trading_usdt"), + "options_funding_usdt": merged.get("options_funding_usdt"), + "options_trading_usdt": merged.get("options_trading_usdt"), "monitored": True, } ) @@ -297,14 +314,21 @@ def build_fund_overview( monitored = True row = _live_row_for_exchange(ex, rows_by_key) fu = tu = total = None + pf = pt = ofu = otu = None data_ok = False - if row and row.get("account_ok"): - fu = _safe_float(row.get("funding_usdt")) - tu = _safe_float(row.get("trading_usdt")) - total = account_total_usdt(fu, tu) - data_ok = total is not None - if data_ok: - live_total += total + caps = ex.get("capabilities") or [] + if row: + merged = merge_board_row_balances({**row, "capabilities": caps}) + if merged.get("data_ok"): + fu = merged.get("funding_usdt") + tu = merged.get("trading_usdt") + total = merged.get("total_usdt") + pf = merged.get("perpetual_funding_usdt") + pt = merged.get("perpetual_trading_usdt") + ofu = merged.get("options_funding_usdt") + otu = merged.get("options_trading_usdt") + data_ok = True + live_total += float(total) live_known += 1 series = _account_series(history, key) if key else [] @@ -329,6 +353,10 @@ def build_fund_overview( "data_ok": data_ok, "funding_usdt": fu, "trading_usdt": tu, + "perpetual_funding_usdt": pf, + "perpetual_trading_usdt": pt, + "options_funding_usdt": ofu, + "options_trading_usdt": otu, "total_usdt": total, "series": series, "drawdown": dd, @@ -387,7 +415,7 @@ def format_fund_history_text( if not history: return "(暂无资金历史快照)" names = account_names or {} - lines = ["【资金快照(资金账户 + 交易账户 USDT)】"] + lines = ["【资金快照(资金账户 + 交易账户 USDT,含期权 USDC≈USDT)】"] for day in sorted(history.keys()): block = history.get(day) or {} ac_map = block.get("accounts") or {} diff --git a/lib/hub/hub_monitor_totals_lib.py b/lib/hub/hub_monitor_totals_lib.py index 6143814..d3beb68 100644 --- a/lib/hub/hub_monitor_totals_lib.py +++ b/lib/hub/hub_monitor_totals_lib.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from lib.hub.hub_options_funds_lib import options_float_pnl_usdt, options_open_position_count + def _coerce_float(value: Any) -> float | None: if value is None or value == "": @@ -54,7 +56,9 @@ def aggregate_monitor_board_totals( win_pnl_u = 0.0 loss_pnl_u = 0.0 open_position_count = 0 + options_open_position_count = 0 float_pnl_u = 0.0 + options_float_pnl_u = 0.0 for row in rows or []: if not isinstance(row, dict): @@ -78,6 +82,15 @@ def aggregate_monitor_board_totals( else: float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) + opt_snap = row.get("options") if "options" in (row.get("capabilities") or []) else None + opt_count = options_open_position_count(opt_snap) + options_open_position_count += opt_count + open_position_count += opt_count + opt_upl = options_float_pnl_usdt(opt_snap) + if opt_upl is not None: + options_float_pnl_u += opt_upl + float_pnl_u += opt_upl + return { "trading_day": trading_day, "reset_hour": int(reset_hour), @@ -89,5 +102,7 @@ def aggregate_monitor_board_totals( "loss_pnl_u": round(loss_pnl_u, 4), "realized_pnl_u": round(win_pnl_u + loss_pnl_u, 4), "open_position_count": open_position_count, + "options_open_position_count": options_open_position_count, "float_pnl_u": round(float_pnl_u, 4), + "options_float_pnl_u": round(options_float_pnl_u, 4), } diff --git a/lib/hub/hub_options_funds_lib.py b/lib/hub/hub_options_funds_lib.py new file mode 100644 index 0000000..7f15c37 --- /dev/null +++ b/lib/hub/hub_options_funds_lib.py @@ -0,0 +1,117 @@ +"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计。""" +from __future__ import annotations + +from typing import Any, Optional + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + v = float(value) + return v if v >= 0 else None + except (TypeError, ValueError): + return None + + +def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]: + fu = _safe_float(funding) + tu = _safe_float(trading) + if fu is None or tu is None: + return None + return round(fu + tu, 4) + + +def stablecoin_usdt_equiv(value: Any) -> Optional[float]: + """USDC / USDT 按 1:1 折算为 USDT 统计口径。""" + return _safe_float(value) + + +def _sum_optional(*values: Any) -> Optional[float]: + parts = [_safe_float(v) for v in values] + present = [p for p in parts if p is not None] + if not present: + return None + return round(sum(present), 4) + + +def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]: + """从期权 snapshot 提取资金户/交易户 USDT 等价余额。""" + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False: + return {"ok": False, "funding_usdt": None, "trading_usdt": None} + if snap.get("ok") is False: + return {"ok": False, "funding_usdt": None, "trading_usdt": None} + bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap + funding = _sum_optional(bal.get("funding_usdt"), bal.get("funding_usdc")) + trading = _sum_optional(bal.get("trading_usdt"), bal.get("trading_usdc")) + ok = funding is not None and trading is not None + return {"ok": ok, "funding_usdt": funding, "trading_usdt": trading} + + +def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return None + upl = snap.get("upl_total_usdc") + if upl is None: + return None + try: + return round(float(upl), 4) + except (TypeError, ValueError): + return None + + +def options_open_position_count(options_snap: dict[str, Any] | None) -> int: + snap = options_snap if isinstance(options_snap, dict) else {} + if snap.get("enabled") is False or snap.get("ok") is False: + return 0 + if snap.get("position_count") is not None: + try: + return max(0, int(snap.get("position_count"))) + except (TypeError, ValueError): + pass + pos = snap.get("positions") + return len(pos) if isinstance(pos, list) else 0 + + +def merge_perp_options_balances( + perpetual_funding_usdt: Any, + perpetual_trading_usdt: Any, + options_snap: dict[str, Any] | None, +) -> dict[str, Any]: + """永续 + 期权余额合并为中控 USDT 统计口径。""" + opt = options_balances_usdt_equiv(options_snap) + funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdt")) + trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdt")) + total = _account_total_usdt(funding, trading) + perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt) + opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt")) + data_ok = total is not None + return { + "perpetual_funding_usdt": _safe_float(perpetual_funding_usdt), + "perpetual_trading_usdt": _safe_float(perpetual_trading_usdt), + "options_funding_usdt": opt.get("funding_usdt"), + "options_trading_usdt": opt.get("trading_usdt"), + "options_ok": bool(opt.get("ok")), + "funding_usdt": funding, + "trading_usdt": trading, + "total_usdt": total, + "perpetual_total_usdt": perp_total, + "options_total_usdt": opt_total, + "data_ok": data_ok, + } + + +def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]: + """监控板行 → 含期权的资金统计。""" + caps = row.get("capabilities") or [] + options_snap = row.get("options") if "options" in caps else None + merged = merge_perp_options_balances( + row.get("funding_usdt") if row.get("account_ok") else None, + row.get("trading_usdt") if row.get("account_ok") else None, + options_snap, + ) + merged["options_float_pnl_u"] = options_float_pnl_usdt(options_snap) + merged["options_open_position_count"] = options_open_position_count(options_snap) + return merged diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index 701da67..2dbe32f 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -21,6 +21,11 @@ from hub_ai.config import ( trading_day_reset_hour, ) from hub_ai.fund_history import format_fund_history_text, get_fund_history, record_fund_snapshot +from lib.hub.hub_options_funds_lib import ( + merge_perp_options_balances, + options_float_pnl_usdt, + options_open_position_count, +) from lib.hub.hub_trades_lib import current_trading_day, summarize_trades _CHAT_CONTEXT_CACHE: dict[str, dict[str, Any]] = {} @@ -413,6 +418,13 @@ def _fetch_account_bundle( "funding_usdt": None, "trading_usdt": None, "available_trading_usdt": None, + "perpetual_funding_usdt": None, + "perpetual_trading_usdt": None, + "options_funding_usdt": None, + "options_trading_usdt": None, + "options_float_pnl_u": None, + "options_open_position_count": 0, + "options_snapshot": None, "trades_yesterday": [], "trade_stats_yesterday": summarize_trades([]), "monitor_lines": {"trends": [], "orders": [], "keys": [], "rolls": []}, @@ -465,8 +477,10 @@ def _fetch_account_bundle( if r.status_code == 200: acct_body = r.json() if isinstance(acct_body, dict) and acct_body.get("ok"): - base["funding_usdt"] = _safe_float(acct_body.get("funding_usdt")) - base["trading_usdt"] = _safe_float(acct_body.get("trading_usdt")) + base["perpetual_funding_usdt"] = _safe_float(acct_body.get("funding_usdt")) + base["perpetual_trading_usdt"] = _safe_float(acct_body.get("trading_usdt")) + base["funding_usdt"] = base["perpetual_funding_usdt"] + base["trading_usdt"] = base["perpetual_trading_usdt"] base["available_trading_usdt"] = _safe_float(acct_body.get("available_trading_usdt")) base["flask_ok"] = True except Exception as exc: @@ -542,6 +556,41 @@ def _fetch_account_bundle( if base["positions"]: _enrich_positions_exchange_tpsl(base["positions"], price_snap, hub_mon) + caps = ex.get("capabilities") or [] + if "options" in caps: + try: + r = client.get( + f"{flask_url}/api/hub/options/snapshot", + headers=_hub_headers(), + timeout=hub_flask_timeout(), + ) + if r.status_code == 200: + opt_body = r.json() + if isinstance(opt_body, dict): + base["options_snapshot"] = opt_body + if opt_body.get("ok") is not False and opt_body.get("enabled") is not False: + base["flask_ok"] = True + merged = merge_perp_options_balances( + base.get("perpetual_funding_usdt"), + base.get("perpetual_trading_usdt"), + opt_body, + ) + base["options_funding_usdt"] = merged.get("options_funding_usdt") + base["options_trading_usdt"] = merged.get("options_trading_usdt") + if merged.get("funding_usdt") is not None: + base["funding_usdt"] = merged.get("funding_usdt") + if merged.get("trading_usdt") is not None: + base["trading_usdt"] = merged.get("trading_usdt") + opt_count = options_open_position_count(opt_body) + base["options_open_position_count"] = opt_count + base["open_position_count"] += opt_count + opt_upl = options_float_pnl_usdt(opt_body) + if opt_upl is not None: + base["options_float_pnl_u"] = opt_upl + base["float_pnl_u"] = round(float(base["float_pnl_u"]) + opt_upl, 4) + except Exception as exc: + base["issues"].append(f"期权接口: {exc}") + if monitored and not base["agent_ok"] and not base["flask_ok"]: base["status"] = "连接异常" elif base["issues"]: @@ -598,6 +647,9 @@ def build_daily_context( total_funding = 0.0 total_trading = 0.0 total_open_positions = 0 + total_options_open_positions = 0 + total_options_float = 0.0 + options_float_known = 0 funding_known = trading_known = 0 for ac in accounts: if ac.get("status") == "未监控": @@ -609,6 +661,11 @@ def build_daily_context( total_loss += int(st.get("loss_count") or 0) total_float += float(ac.get("float_pnl_u") or 0) total_open_positions += int(ac.get("open_position_count") or _account_open_position_count(ac)) + total_options_open_positions += int(ac.get("options_open_position_count") or 0) + opt_float = _safe_float(ac.get("options_float_pnl_u")) + if opt_float is not None: + total_options_float += opt_float + options_float_known += 1 fu = _safe_float(ac.get("funding_usdt")) tu = _safe_float(ac.get("trading_usdt")) if fu is not None: @@ -631,6 +688,8 @@ def build_daily_context( "loss_count": total_loss, "float_pnl_u": round(total_float, 4), "open_position_count": total_open_positions, + "options_open_position_count": total_options_open_positions, + "options_float_pnl_u": round(total_options_float, 4) if options_float_known else None, "total_funding_usdt": round(total_funding, 4) if total_funding is not None else None, "total_trading_usdt": round(total_trading, 4) if total_trading is not None else None, } @@ -929,6 +988,25 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: "pnl": round(upnl, 4), } ) + opt_snap = ac.get("options_snapshot") if isinstance(ac.get("options_snapshot"), dict) else {} + if opt_snap.get("ok") is not False and opt_snap.get("enabled") is not False: + for p in opt_snap.get("positions") or []: + if not isinstance(p, dict): + continue + inst = p.get("inst_id") or "?" + opt_type = (p.get("opt_type") or "").upper() + label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else opt_type or "OPT" + upl = p.get("upl") + line: dict[str, Any] = { + "kind": "options", + "text": f"期权 {inst} {label}", + } + if upl is not None: + try: + line["pnl"] = round(float(upl), 4) + except (TypeError, ValueError): + pass + position_lines.append(line) issues = [str(x) for x in (ac.get("issues") or [])[:3]] return { "monitor_counts": { diff --git a/manual_trading_hub/hub_dashboard.py b/manual_trading_hub/hub_dashboard.py index 7ed615b..5b86654 100644 --- a/manual_trading_hub/hub_dashboard.py +++ b/manual_trading_hub/hub_dashboard.py @@ -55,6 +55,12 @@ def _enrich_account_row(ac: dict) -> dict: "monitored": ac.get("status") != "未监控", "funding_usdt": ac.get("funding_usdt"), "trading_usdt": ac.get("trading_usdt"), + "perpetual_funding_usdt": ac.get("perpetual_funding_usdt"), + "perpetual_trading_usdt": ac.get("perpetual_trading_usdt"), + "options_funding_usdt": ac.get("options_funding_usdt"), + "options_trading_usdt": ac.get("options_trading_usdt"), + "options_float_pnl_u": ac.get("options_float_pnl_u"), + "options_open_position_count": ac.get("options_open_position_count"), "capital_total_usdt": round(capital, 4) if capital is not None else None, "available_trading_usdt": ac.get("available_trading_usdt"), "pnl_u": st.get("total_pnl_u"), diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index de27944..8b7cf5b 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -60,6 +60,8 @@ const floating = Number(totals.float_pnl_u); const funding = totals.total_funding_usdt; const trading = totals.total_trading_usdt; + const optionsFloat = Number(totals.options_float_pnl_u); + const optionsPos = totals.options_open_position_count; elKpi.innerHTML = [ kpiCard("交易日", esc(totals.trading_day || "—"), ""), kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)), @@ -76,10 +78,20 @@ ? `${fmt(Number(funding) + Number(trading), 2)}U` : "—", "", - `资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}` + `永续+期权 USDT 等价 · 资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}` ), - kpiCard("实盘持仓", `${totals.open_position_count || 0} 仓`, ""), - ].join(""); + kpiCard( + "实盘持仓", + `${totals.open_position_count || 0} 仓`, + "", + Number.isFinite(optionsPos) && optionsPos > 0 ? `含期权 ${optionsPos} 仓` : "" + ), + Number.isFinite(optionsFloat) && Math.abs(optionsFloat) > 1e-9 + ? kpiCard("期权浮盈", pnlSigned(optionsFloat, 2), pnlClass(optionsFloat)) + : "", + ] + .filter(Boolean) + .join(""); } function kpiCard(label, value, valCls, sub) { @@ -183,6 +195,24 @@ alert && barW > 0 ? `
` : ""; + const optFunding = ac.options_funding_usdt; + const optTrading = ac.options_trading_usdt; + const optFloat = Number(ac.options_float_pnl_u); + const optPos = Number(ac.options_open_position_count) || 0; + const optMetrics = + optFunding != null || optTrading != null || optPos > 0 + ? `
期权资金${fmt( + optFunding != null && optTrading != null + ? Number(optFunding) + Number(optTrading) + : null, + 2 + )}U
+
期权持仓${optPos} 仓
+
期权浮盈${pnlSigned( + optFloat, + 2 + )}
` + : ""; return `
${esc(ac.name || "—")}
@@ -195,6 +225,7 @@
今日盈亏${pnlSigned(pnl, 2)}
平仓笔数${Number(ac.closed_count) || 0}
浮盈亏${pnlSigned(floatPnl, 2)}
+ ${optMetrics}
${lossBar} ${renderAccountDetail(ac)} diff --git a/manual_trading_hub/static/funds.js b/manual_trading_hub/static/funds.js index 68a9669..5f485df 100644 --- a/manual_trading_hub/static/funds.js +++ b/manual_trading_hub/static/funds.js @@ -176,6 +176,18 @@ monitored && ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—"; const trading = monitored && ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—"; + const optFunding = + monitored && ac.options_funding_usdt != null ? fmt(ac.options_funding_usdt, 2) + " U" : ""; + const optTrading = + monitored && ac.options_trading_usdt != null ? fmt(ac.options_trading_usdt, 2) + " U" : ""; + const optLine = + optFunding || optTrading + ? '
期权户' + + (optFunding || "—") + + " / " + + (optTrading || "—") + + "
" + : ""; const dd = ac.drawdown || {}; const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—"; const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—"; @@ -214,6 +226,7 @@ '
交易户' + trading + "
" + + optLine + '
较昨日' + @@ -266,7 +279,7 @@ if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—"; if (elFsSub) { const parts = [ - "资金户 + 交易户(不含浮盈)", + "资金户 + 交易户 + 期权户(USDC≈USDT,不含浮盈)", "交易日 " + (meta.trading_day || "—"), "自 " + (meta.history_start_day || "—") + " 起", ]; @@ -327,7 +340,7 @@ const hour = data && data.reset_hour != null ? data.reset_hour : 8; if (elDescBody) { elDescBody.textContent = - "总资金 = 各监控户(资金账户 + 交易账户);自 " + + "总资金 = 各监控户(永续资金账户 + 交易账户 + 期权账户,USDC 按 1:1 计入 USDT);自 " + start + " 起按北京时间 " + hour + diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f8efcfa..661ecbc 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1145,8 +1145,8 @@ - - + + diff --git a/tests/test_hub_options_funds_lib.py b/tests/test_hub_options_funds_lib.py new file mode 100644 index 0000000..8f28fa3 --- /dev/null +++ b/tests/test_hub_options_funds_lib.py @@ -0,0 +1,53 @@ +from unittest import TestCase + +from lib.hub.hub_options_funds_lib import ( + merge_board_row_balances, + merge_perp_options_balances, + options_balances_usdt_equiv, +) + + +class HubOptionsFundsLibTests(TestCase): + def test_options_balances_usdt_equiv(self): + snap = { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 10, "trading_usdt": 5, "trading_usdc": 2}, + } + out = options_balances_usdt_equiv(snap) + self.assertTrue(out["ok"]) + self.assertEqual(out["funding_usdt"], 10.0) + self.assertEqual(out["trading_usdt"], 7.0) + + def test_merge_perp_options_balances(self): + out = merge_perp_options_balances( + 100, + 50, + { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 8, "trading_usdc": 4}, + }, + ) + self.assertEqual(out["funding_usdt"], 108.0) + self.assertEqual(out["trading_usdt"], 54.0) + self.assertEqual(out["total_usdt"], 162.0) + + def test_merge_board_row_balances(self): + row = { + "account_ok": True, + "funding_usdt": 20, + "trading_usdt": 30, + "capabilities": ["options"], + "options": { + "ok": True, + "enabled": True, + "balances": {"funding_usdc": 1, "trading_usdc": 2}, + "positions": [{"inst_id": "X"}], + "upl_total_usdc": 0.5, + }, + } + out = merge_board_row_balances(row) + self.assertEqual(out["total_usdt"], 53.0) + self.assertEqual(out["options_open_position_count"], 1) + self.assertEqual(out["options_float_pnl_u"], 0.5)