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 `