diff --git a/lib/hedge_plan/hedge_plan_db.py b/lib/hedge_plan/hedge_plan_db.py index 43e24fa..5458ada 100644 --- a/lib/hedge_plan/hedge_plan_db.py +++ b/lib/hedge_plan/hedge_plan_db.py @@ -240,6 +240,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s "underlying": row.get("underlying"), "opt_type": opt_type, "target_index": target_f, + "plan_type": "options_options", "managed_by": "hedge_plan", } return out diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 26fac86..8f682e5 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -175,6 +175,7 @@ def build_hub_monitor_payload( orders, trends, rolls, + hedges=None, enrich=None, risk_status=None, ) -> dict: @@ -185,6 +186,7 @@ def build_hub_monitor_payload( "orders": orders, "trends": trends, "rolls": rolls, + "hedges": hedges if isinstance(hedges, list) else [], "key_prices": [], } if isinstance(risk_status, dict): @@ -193,6 +195,9 @@ def build_hub_monitor_payload( extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls) if isinstance(extra, dict): payload.update(extra) + # enrich 可能不返回 hedges,保留本地组装的对冲列表. + if "hedges" not in extra: + payload["hedges"] = hedges if isinstance(hedges, list) else [] return payload @@ -572,6 +577,17 @@ def register_hub_routes(app): rolls.append(_row_to_dict(row)) except Exception: pass + hedges = [] + try: + from lib.hedge_plan.hedge_plan_db import attach_legs_to_plans, list_plans + + hedge_rows: list = [] + for st in ("opening", "active", "partial"): + hedge_rows.extend(list_plans(conn, status=st, limit=80)) + hedge_rows.sort(key=lambda row: int(row.get("id") or 0), reverse=True) + hedges = attach_legs_to_plans(conn, hedge_rows) + except Exception: + hedges = [] risk_status = None risk_fn = c.get("risk_status_fn") if callable(risk_fn): @@ -589,6 +605,7 @@ def register_hub_routes(app): orders=orders, trends=trends, rolls=rolls, + hedges=hedges, enrich=enrich, risk_status=risk_status, ) @@ -601,6 +618,7 @@ def register_hub_routes(app): orders=orders, trends=trends, rolls=rolls, + hedges=hedges, risk_status=risk_status, ) ) diff --git a/lib/options/options_hub_lib.py b/lib/options/options_hub_lib.py index 69d5d36..7854bb0 100644 --- a/lib/options/options_hub_lib.py +++ b/lib/options/options_hub_lib.py @@ -53,6 +53,20 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]: if not mon: # 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。 p["target_index"] = hedge_target.get("target_index") + try: + from lib.instance.instance_dashboard_lib import ( + _format_options_target, + _resolve_options_source, + ) + + inst = str(p.get("inst_id") or "") + source_key, source_label = _resolve_options_source(conn, inst) + p["source"] = source_key + p["source_label"] = source_label + p["target_monitor_text"] = _format_options_target(p) + except Exception: + p.setdefault("source_label", "—") + p.setdefault("target_monitor_text", "—") finally: conn.close() except Exception: diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 41edd54..1c885a1 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -330,6 +330,8 @@ async def _run_board_aggregate() -> dict: await asyncio.to_thread(record_fund_snapshot_from_board, body.get("rows") or []) except Exception: pass + # 监控聚合完成即唤醒数据看板,持仓来源与监控 5s 同步. + dashboard_store.request_refresh() return {"ok": True, **body} except asyncio.TimeoutError: return { diff --git a/manual_trading_hub/hub_ai/context.py b/manual_trading_hub/hub_ai/context.py index f360a38..c4aa790 100644 --- a/manual_trading_hub/hub_ai/context.py +++ b/manual_trading_hub/hub_ai/context.py @@ -973,9 +973,119 @@ def format_account_remark(ac: dict) -> str: return ";".join(parts) +def _monitor_item_matches_position(item: dict, symbol: str, side: str) -> bool: + o_sym = item.get("exchange_symbol") or item.get("symbol") or "" + if not _symbols_match(symbol, o_sym): + return False + return (str(item.get("direction") or "").lower() == str(side or "").lower()) + + +def _order_monitor_source_label(order: dict) -> tuple[int, str]: + """返回 (优先级, 来源标签). 对冲=1 … 关键位=5.""" + mt = str( + order.get("monitor_type_display") + or order.get("monitor_type_label") + or order.get("monitor_type") + or "" + ).strip() + if "顺势" in mt: + return 2, "顺势加仓" + if "趋势" in mt: + return 3, "趋势回调" + if "关键位" in mt: + return 5, "关键位" + return 4, "下单监控" + + +def _hedge_matches_position(plan: dict, symbol: str, side: str) -> bool: + """进行中对冲计划是否覆盖该永续仓(方向 + 永续腿/标的).""" + direction = str(plan.get("direction") or "").lower() + if direction and direction != str(side or "").lower(): + return False + for leg in plan.get("legs") or []: + if not isinstance(leg, dict): + continue + if str(leg.get("leg_role") or "") != "perp": + continue + if str(leg.get("status") or "open") not in ("", "open"): + continue + if _symbols_match(symbol, str(leg.get("symbol") or "")): + return True + und = str(plan.get("underlying") or "").strip() + if und and _symbols_match(symbol, und): + return True + return False + + +def _hedge_source_label(plan: dict) -> str: + pt = str(plan.get("plan_type") or "").strip() + if pt == "perp_options" or str(plan.get("plan_type_label") or "") == "永期对冲": + return "永期对冲" + if pt == "options_options" or str(plan.get("plan_type_label") or "") == "期期对冲": + return "期期对冲" + return "对冲" + + +def resolve_position_monitor_source(pos: dict, hub_mon: Optional[dict]) -> str: + """仓位来源:对冲 > 顺势加仓 > 趋势回调 > 下单监控 > 关键位;对不上为 —.""" + if not isinstance(hub_mon, dict) or hub_mon.get("ok") is False: + return "—" + sym = str(pos.get("symbol") or "") + side = str(pos.get("side") or "") + if not sym: + return "—" + candidates: list[tuple[int, str]] = [] + for h in hub_mon.get("hedges") or []: + if isinstance(h, dict) and _hedge_matches_position(h, sym, side): + candidates.append((1, _hedge_source_label(h))) + for r in hub_mon.get("rolls") or []: + if isinstance(r, dict) and _monitor_item_matches_position(r, sym, side): + candidates.append((2, "顺势加仓")) + for t in hub_mon.get("trends") or []: + if isinstance(t, dict) and _monitor_item_matches_position(t, sym, side): + candidates.append((3, "趋势回调")) + for o in hub_mon.get("orders") or []: + if isinstance(o, dict) and _monitor_item_matches_position(o, sym, side): + candidates.append(_order_monitor_source_label(o)) + for k in hub_mon.get("keys") or []: + if isinstance(k, dict) and _monitor_item_matches_position(k, sym, side): + candidates.append((5, "关键位")) + if not candidates: + return "—" + candidates.sort(key=lambda x: x[0]) + return candidates[0][1] + + +def _options_source_label(p: dict) -> str: + """看板期权来源:仅对冲标期期/永期;纯期权或对不上监控显示 —.""" + source = str(p.get("source") or "").strip() + label = str(p.get("source_label") or "").strip() + if source == "perp_options" or label == "永期对冲": + return "永期对冲" + if source == "options_options" or label == "期期对冲": + return "期期对冲" + hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None + if hedge: + return _hedge_source_label(hedge) + return "—" + + +def _options_target_monitor_text(p: dict) -> str: + raw = p.get("target_monitor_text") + if raw not in (None, ""): + return str(raw) + try: + from lib.instance.instance_dashboard_lib import _format_options_target + + return _format_options_target(p) + except Exception: + return "—" + + def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: - """数据看板分户卡片:监控仅数量,持仓逐行(含浮盈亏与来源).""" + """数据看板分户卡片:监控数量 + 持仓表(来源=监控匹配).""" mon = ac.get("monitor_lines") or {} + hub_mon = ac.get("hub_monitor") if isinstance(ac.get("hub_monitor"), dict) else None position_lines: list[dict[str, Any]] = [] for p in _filter_open_positions(ac.get("positions") or []): sym = p.get("symbol") or "?" @@ -984,10 +1094,11 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: if contracts is None: contracts = p.get("size") upnl = _position_float_pnl(p) + source = resolve_position_monitor_source(p, hub_mon) position_lines.append( { "kind": "position", - "source": "永续", + "source": source, "symbol": sym, "side": side, "contracts": contracts, @@ -998,28 +1109,21 @@ def format_dashboard_account_detail(ac: dict) -> dict[str, Any]: opt_snap = ac.get("options_snapshot") if isinstance(ac.get("options_snapshot"), dict) else {} options_positions: list[dict[str, Any]] = [] if opt_snap.get("ok") is not False and opt_snap.get("enabled") is not False: - from lib.options.options_pricing_lib import format_options_breakeven_line - for p in opt_snap.get("positions") or []: if not isinstance(p, dict): continue - options_positions.append(p) - inst = p.get("inst_id") or "?" - opt_type = (p.get("opt_type") or "").upper() + row = dict(p) + row["source_label"] = _options_source_label(p) + row["target_monitor_text"] = _options_target_monitor_text(p) + options_positions.append(row) + inst = row.get("inst_id") or "?" + opt_type = (row.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") - be_line = format_options_breakeven_line( - expiry_be_px=p.get("expiry_be_px"), - close_be_px=p.get("close_be_px"), - idx_px=p.get("idx_px"), - ) - text = f"期权 {inst} {label}" - if be_line: - text = f"{text} {be_line}" + upl = row.get("upl") line: dict[str, Any] = { "kind": "options", - "source": "期权", - "text": text, + "source": row.get("source_label") or "—", + "text": f"期权 {inst} {label}", } if upl is not None: try: diff --git a/manual_trading_hub/hub_dashboard.py b/manual_trading_hub/hub_dashboard.py index db22eec..9a40e46 100644 --- a/manual_trading_hub/hub_dashboard.py +++ b/manual_trading_hub/hub_dashboard.py @@ -1,6 +1,7 @@ """中控数据看板:三户当日总览(无 AI,纯数据聚合).""" from __future__ import annotations +import os from datetime import datetime, timezone from typing import Any, Optional @@ -14,7 +15,8 @@ from hub_ai.config import trading_day_reset_hour from lib.hub.hub_trades_lib import current_trading_day LOSS_ALERT_PCT = 5.0 -DASHBOARD_POLL_INTERVAL_SEC = 60 +# 与监控区 board 默认 5s 对齐,看板持仓来源跟监控同步. +DASHBOARD_POLL_INTERVAL_SEC = float(os.getenv("DASHBOARD_POLL_INTERVAL_SEC", "5")) def _safe_float(v: Any) -> Optional[float]: diff --git a/manual_trading_hub/static/dashboard.css b/manual_trading_hub/static/dashboard.css index a86ee8e..35c1893 100644 --- a/manual_trading_hub/static/dashboard.css +++ b/manual_trading_hub/static/dashboard.css @@ -373,6 +373,51 @@ body.hub-page-dashboard .page#page-dashboard { letter-spacing: 0.02em; } +.dash-pos-source.is-hedge { + color: #fbbf24; + background: rgba(245, 158, 11, 0.16); + border: 1px solid rgba(245, 158, 11, 0.4); +} + +.dash-pos-source.is-roll { + color: #6ee7b7; + background: rgba(16, 185, 129, 0.16); + border: 1px solid rgba(16, 185, 129, 0.4); +} + +.dash-pos-source.is-trend { + color: #93c5fd; + background: rgba(59, 130, 246, 0.18); + border: 1px solid rgba(59, 130, 246, 0.35); +} + +.dash-pos-source.is-order { + color: #c4b5fd; + background: rgba(139, 92, 246, 0.18); + border: 1px solid rgba(139, 92, 246, 0.35); +} + +.dash-pos-source.is-key { + color: #fdba74; + background: rgba(249, 115, 22, 0.16); + border: 1px solid rgba(249, 115, 22, 0.4); +} + +.dash-pos-source.is-none { + color: var(--dash-muted); + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.28); +} + +.dash-target-monitor { + color: var(--dash-muted); +} + +.dash-target-monitor.is-on { + color: #4ade80; + font-weight: 600; +} + .dash-pos-source.is-perp { color: #93c5fd; background: rgba(59, 130, 246, 0.18); diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index e6ae5e6..f5614f3 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -144,19 +144,29 @@ return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0); } + function sourceBadgeClass(source) { + const s = String(source || ""); + if (s.indexOf("对冲") >= 0) return "is-hedge"; + if (s.indexOf("顺势") >= 0) return "is-roll"; + if (s.indexOf("趋势") >= 0) return "is-trend"; + if (s.indexOf("关键位") >= 0) return "is-key"; + if (s.indexOf("下单") >= 0) return "is-order"; + return "is-none"; + } + function renderDashboardPerpTable(lines) { const rows = Array.isArray(lines) ? lines : []; if (!rows.length) return ""; const body = rows .map((ln) => { - const source = esc((ln && ln.source) || "永续"); + const source = String((ln && ln.source) || "—"); const symbol = esc((ln && (ln.symbol || ln.text)) || "—"); const side = esc((ln && ln.side) || "—"); const contracts = ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—"; const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN; return ` - ${source} + ${esc(source)} ${symbol} ${side} ${contracts} @@ -188,14 +198,16 @@ : (p.opt_type || "").toUpperCase() === "P" ? "Put" : p.opt_type || "—"; + const source = String(p.source_label || p.source || "—"); + const target = String(p.target_monitor_text || "—"); + const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor"; return ` - 期权 + ${esc(source)} ${esc(shortDashInst(p.inst_id))} ${esc(optType)} ${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)} ${p.idx_px != null ? fmt(p.idx_px, 0) : "—"} - ${p.expiry_be_px != null ? fmt(p.expiry_be_px, 0) : "—"} - ${p.close_be_px != null ? fmt(p.close_be_px, 0) : "—"} + ${esc(target)} ${p.upl != null ? pnlSigned(p.upl, 2) : "—"} `; }) @@ -205,7 +217,7 @@
- + ${rows}
来源合约类型到期倒计时指数到期平衡平掉回本浮盈来源合约类型到期倒计时指数目标监控浮盈
@@ -360,7 +372,7 @@ const ver = Number(data.dashboard_version) || 0; if (ver) localDashVersion = ver; renderPayload(data); - const sec = Number(data.poll_interval_sec) || 60; + const sec = Number(data.poll_interval_sec) || 5; setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`); } catch (e) { setStatus(String(e.message || e), true); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 08991b8..43ac106 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -19,7 +19,7 @@ - + @@ -1373,7 +1373,7 @@ - + diff --git a/tests/test_dashboard_position_source.py b/tests/test_dashboard_position_source.py new file mode 100644 index 0000000..c5f7dfe --- /dev/null +++ b/tests/test_dashboard_position_source.py @@ -0,0 +1,63 @@ +"""数据看板仓位来源:监控匹配优先级.""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "manual_trading_hub")) +sys.path.insert(0, str(ROOT)) + +from hub_ai.context import ( # noqa: E402 + _options_source_label, + resolve_position_monitor_source, +) + + +class TestDashboardPositionSource(unittest.TestCase): + def test_priority_hedge_over_roll(self): + hub = { + "ok": True, + "hedges": [ + { + "plan_type": "perp_options", + "direction": "long", + "legs": [{"leg_role": "perp", "symbol": "ETH/USDT:USDT", "status": "open"}], + } + ], + "rolls": [{"symbol": "ETH/USDT:USDT", "direction": "long"}], + } + self.assertEqual( + resolve_position_monitor_source({"symbol": "ETH/USDT:USDT", "side": "long"}, hub), + "永期对冲", + ) + + def test_unmatched_is_dash(self): + hub = {"ok": True, "orders": [], "trends": [], "rolls": [], "keys": [], "hedges": []} + self.assertEqual( + resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub), + "—", + ) + + def test_roll_beats_order(self): + hub = { + "ok": True, + "rolls": [{"symbol": "BTC/USDT:USDT", "direction": "short"}], + "orders": [{"symbol": "BTC/USDT:USDT", "direction": "short", "monitor_type": "下单监控"}], + } + self.assertEqual( + resolve_position_monitor_source({"symbol": "BTC/USDT:USDT", "side": "short"}, hub), + "顺势加仓", + ) + + def test_options_plain_is_dash(self): + self.assertEqual(_options_source_label({"source": "option", "source_label": "纯期权"}), "—") + self.assertEqual( + _options_source_label({"source": "options_options", "source_label": "期期对冲"}), + "期期对冲", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_monitor_payload.py b/tests/test_hub_monitor_payload.py index 2dca6a9..3612558 100644 --- a/tests/test_hub_monitor_payload.py +++ b/tests/test_hub_monitor_payload.py @@ -32,6 +32,7 @@ class TestHubMonitorPayload(unittest.TestCase): self.assertEqual(out["keys"], keys) self.assertEqual(out["orders"], orders) self.assertEqual(out["rolls"], rolls) + self.assertEqual(out["hedges"], []) self.assertEqual(out["trends"][0]["add_count"], 2)