diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 62a8f2a..3ec2f3d 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -630,6 +630,7 @@ def register_hub_routes(app): fetch_trades_for_trading_day, summarize_trades, ) + from lib.trade.daily_open_limit_lib import count_opens_for_trading_day c = _ctx() get_db = c.get("get_db") @@ -651,6 +652,7 @@ def register_hub_routes(app): row_to_dict_fn=c.get("row_to_dict"), reset_hour=reset_hour, ) + opens_today = count_opens_for_trading_day(conn, trading_day) finally: conn.close() stats = summarize_trades(trades) @@ -659,6 +661,7 @@ def register_hub_routes(app): "ok": True, "trading_day": trading_day, "trading_day_reset_hour": reset_hour, + "opens_today": opens_today, "trades": trades, "stats": stats, } diff --git a/lib/hub/hub_monitor_totals_lib.py b/lib/hub/hub_monitor_totals_lib.py new file mode 100644 index 0000000..6143814 --- /dev/null +++ b/lib/hub/hub_monitor_totals_lib.py @@ -0,0 +1,93 @@ +"""监控区看板:三所当日统计聚合。""" +from __future__ import annotations + +from typing import Any + + +def _coerce_float(value: Any) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def position_unrealized_pnl(pos: dict[str, Any]) -> float: + for key in ("unrealized_pnl", "unrealizedPnl", "upnl"): + v = _coerce_float(pos.get(key)) + if v is not None: + return v + return 0.0 + + +def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(agent, dict): + return [] + positions = agent.get("positions") + if not isinstance(positions, list): + return [] + out: list[dict[str, Any]] = [] + for p in positions: + if not isinstance(p, dict): + continue + try: + c = abs(float(p.get("contracts") or 0)) + except (TypeError, ValueError): + c = 0.0 + if c > 1e-12: + out.append(p) + return out + + +def aggregate_monitor_board_totals( + rows: list[dict[str, Any]], + *, + trading_day: str, + reset_hour: int = 8, +) -> dict[str, Any]: + """汇总监控 board 各行 → 左上统计卡数据。""" + open_count = 0 + closed_count = 0 + win_count = 0 + loss_count = 0 + win_pnl_u = 0.0 + loss_pnl_u = 0.0 + open_position_count = 0 + float_pnl_u = 0.0 + + for row in rows or []: + if not isinstance(row, dict): + continue + day_stats = row.get("day_stats") if isinstance(row.get("day_stats"), dict) else {} + if day_stats.get("ok"): + open_count += int(day_stats.get("opens_today") or 0) + st = day_stats.get("trade_stats") if isinstance(day_stats.get("trade_stats"), dict) else {} + closed_count += int(st.get("closed_count") or 0) + win_count += int(st.get("win_count") or 0) + loss_count += int(st.get("loss_count") or 0) + win_pnl_u += float(st.get("win_pnl_u") or 0) + loss_pnl_u += float(st.get("loss_pnl_u") or 0) + + ag = row.get("agent") if isinstance(row.get("agent"), dict) else {} + open_pos = _open_positions(ag) + open_position_count += len(open_pos) + agent_upnl = _coerce_float(ag.get("total_unrealized_pnl")) + if agent_upnl is not None: + float_pnl_u += agent_upnl + else: + float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos) + + return { + "trading_day": trading_day, + "reset_hour": int(reset_hour), + "open_count": open_count, + "closed_count": closed_count, + "win_count": win_count, + "loss_count": loss_count, + "win_pnl_u": round(win_pnl_u, 4), + "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, + "float_pnl_u": round(float_pnl_u, 4), + } diff --git a/lib/hub/hub_trades_lib.py b/lib/hub/hub_trades_lib.py index 292cda3..162f6da 100644 --- a/lib/hub/hub_trades_lib.py +++ b/lib/hub/hub_trades_lib.py @@ -616,6 +616,8 @@ def fetch_trades_for_archive( def summarize_trades(trades: list[dict]) -> dict[str, Any]: """单笔列表 → 笔数 / 盈亏 / 胜败统计。""" total_pnl = 0.0 + win_pnl = 0.0 + loss_pnl = 0.0 win = loss = flat = 0 for t in trades or []: try: @@ -625,8 +627,10 @@ def summarize_trades(trades: list[dict]) -> dict[str, Any]: total_pnl += pnl if pnl > 1e-9: win += 1 + win_pnl += pnl elif pnl < -1e-9: loss += 1 + loss_pnl += pnl else: flat += 1 return { @@ -635,4 +639,6 @@ def summarize_trades(trades: list[dict]) -> dict[str, Any]: "loss_count": loss, "flat_count": flat, "total_pnl_u": round(total_pnl, 4), + "win_pnl_u": round(win_pnl, 4), + "loss_pnl_u": round(loss_pnl, 4), } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index b598d41..78d9e9b 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -14,6 +14,8 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) +from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals +from lib.hub.hub_trades_lib import current_trading_day from lib.hub.hub_order_sync_lib import ( cond_order_role, dedupe_conditional_orders_by_role, @@ -1493,13 +1495,19 @@ async def _fetch_flask_json( method: str = "GET", data=None, json_body: dict | None = None, + params: dict | None = None, ) -> dict | None: base = (ex.get("flask_url") or "").rstrip("/") if not base: return None try: if method == "GET": - r = await client.get(f"{base}{path}", headers=_hub_headers(), timeout=HUB_FLASK_TIMEOUT) + r = await client.get( + f"{base}{path}", + headers=_hub_headers(), + timeout=HUB_FLASK_TIMEOUT, + params=params or None, + ) else: headers = {**_hub_headers(), "Content-Type": "application/json"} if json_body is not None: @@ -2036,15 +2044,16 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict async def _fetch_exchange_flask_bundle( - client: httpx.AsyncClient, ex: dict -) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None]: - """单所 Flask:monitor / meta / price_snapshot / account(有 flask_url 时)并行拉取。""" + client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None +) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]: + """单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取。""" caps = ex.get("capabilities") or [] tasks = [ _fetch_flask_json(client, ex, "/api/hub/monitor"), _fetch_flask_json(client, ex, "/api/hub/meta"), ] has_flask = bool((ex.get("flask_url") or "").strip()) + day = (trading_day or "").strip() if has_flask: tasks.extend( [ @@ -2052,11 +2061,21 @@ async def _fetch_exchange_flask_bundle( _fetch_flask_json(client, ex, "/api/hub/account"), ] ) + if day: + tasks.append( + _fetch_flask_json( + client, + ex, + "/api/hub/trades/today", + params={"trading_day": day}, + ) + ) results = await asyncio.gather(*tasks) hub_mon = results[0] meta = results[1] 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 key_prices = None want_prices = HUB_BOARD_KEY_PRICES and "key" in caps if want_prices and isinstance(snap, dict): @@ -2067,14 +2086,34 @@ async def _fetch_exchange_flask_bundle( key_prices, snap if isinstance(snap, dict) else None, account if isinstance(account, dict) else None, + trades_today if isinstance(trades_today, dict) else None, ) +def _trading_day_reset_hour() -> int: + try: + return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8") + except ValueError: + return 8 + + +def _day_stats_from_trades_body(body: dict | None) -> dict: + if not isinstance(body, dict) or not body.get("ok"): + return {"ok": False} + stats = body.get("stats") if isinstance(body.get("stats"), dict) else {} + return { + "ok": True, + "trading_day": body.get("trading_day"), + "opens_today": int(body.get("opens_today") or 0), + "trade_stats": stats, + } + + async def _assemble_board_row( - client: httpx.AsyncClient, ex: dict, agent_row: dict + client: httpx.AsyncClient, ex: dict, agent_row: dict, *, trading_day: str ) -> dict: - hub_mon, meta, key_prices, snap, account = await _fetch_exchange_flask_bundle( - client, ex + hub_mon, meta, key_prices, snap, account, trades_today = await _fetch_exchange_flask_bundle( + client, ex, trading_day=trading_day ) if isinstance(hub_mon, dict): _merge_flask_order_price_fields(hub_mon, snap) @@ -2101,23 +2140,31 @@ async def _assemble_board_row( "trading_usdt": account.get("trading_usdt") if acct_ok else None, "available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None, "account_ok": acct_ok, + "day_stats": _day_stats_from_trades_body(trades_today), } async def _build_monitor_board_payload() -> dict: exchanges = enabled_exchanges() + reset_hour = _trading_day_reset_hour() + trading_day = current_trading_day(reset_hour=reset_hour) async with httpx.AsyncClient() as client: agent_rows = await asyncio.gather( *[_fetch_agent_status(client, ex) for ex in exchanges] ) out = await asyncio.gather( *[ - _assemble_board_row(client, ex, agent_row) + _assemble_board_row(client, ex, agent_row, trading_day=trading_day) for ex, agent_row in zip(exchanges, agent_rows) ] ) + rows = list(out) + totals = aggregate_monitor_board_totals( + rows, trading_day=trading_day, reset_hour=reset_hour + ) return { - "rows": list(out), + "rows": rows, + "totals": totals, "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"), } diff --git a/manual_trading_hub/hub_board_cache.py b/manual_trading_hub/hub_board_cache.py index 65cdfb6..b711c08 100644 --- a/manual_trading_hub/hub_board_cache.py +++ b/manual_trading_hub/hub_board_cache.py @@ -58,6 +58,7 @@ class MonitorBoardStore: "ok": p.get("ok", True) if self.payload else False, "board_version": self.version, "rows": rows, + "totals": p.get("totals") if isinstance(p.get("totals"), dict) else None, "updated_at": p.get("updated_at"), "aggregating": self.aggregating, "error": self.last_error or p.get("error"), diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 399f720..09b1850 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -1153,7 +1153,59 @@ html[data-theme="light"] .host-metric-bar { display: grid; gap: 16px; /* 列数由 app.js syncMonitorGridColumns 按卡片数量设置 */ + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-monitor.grid-monitor-2x2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid-monitor.grid-monitor-with-stats { + grid-template-columns: 1fr; +} + +.monitor-stats-card .card-head { + padding-bottom: 0; +} + +.monitor-stats-grid { + display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 14px; +} + +.monitor-stat-cell { + min-width: 0; + padding: 10px 10px 8px; + border-radius: 10px; + border: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--panel-solid) 88%, transparent); +} + +.monitor-stat-label { + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} + +.monitor-stat-value { + font-family: var(--display); + font-size: 22px; + font-weight: 600; + line-height: 1.15; +} + +.monitor-stat-sub { + margin-top: 4px; + font-size: 11px; + color: var(--muted); + line-height: 1.3; +} + +@media (max-width: 720px) { + .monitor-stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .card-expand-zone { diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index adcddcc..babf19a 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -111,6 +111,7 @@ } let tpslPending = null; let lastMonitorRows = []; + let lastMonitorTotals = null; let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; @@ -1307,15 +1308,16 @@ }, 12000); } - function saveMonitorBoardCache(rows, updatedAt, boardVersion) { + function saveMonitorBoardCache(rows, updatedAt, boardVersion, totals) { try { sessionStorage.setItem( HUB_MONITOR_BOARD_CACHE_KEY, JSON.stringify({ - version: 1, + version: 2, board_version: boardVersion != null ? boardVersion : localBoardVersion, updated_at: updatedAt || "", rows: rows || [], + totals: totals || null, saved_at: Date.now(), }) ); @@ -1343,6 +1345,7 @@ const cached = loadMonitorBoardFromCache(); if (!cached) return false; lastMonitorRows = cached.rows; + lastMonitorTotals = cached.totals || null; lastMonitorBoardUpdatedAt = cached.updated_at || ""; localBoardVersion = 0; applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); @@ -1602,18 +1605,25 @@ el.innerHTML = `正常 ${ok}·关注 ${warn}·异常 ${err}`; } - /** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */ - function syncMonitorGridColumns(gridEl, count) { + /** 监控卡片列数:桌面 2×2(统计+三所);手机 2 列瓦片 */ + function syncMonitorGridColumns(gridEl, itemCount, opts) { if (!gridEl) return; + const options = opts || {}; if (isMobileLayout()) { + gridEl.style.gridTemplateColumns = options.statsFirst + ? "1fr" + : "repeat(2, minmax(0, 1fr))"; + return; + } + if (options.statsFirst) { gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))"; return; } let cols = 3; - if (count <= 1) cols = 1; - else if (count === 2) cols = 2; - else if (count === 3) cols = 3; - else if (count === 4) cols = 2; + if (itemCount <= 1) cols = 1; + else if (itemCount === 2) cols = 2; + else if (itemCount === 3) cols = 3; + else if (itemCount === 4) cols = 2; else cols = 3; gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; } @@ -1800,7 +1810,9 @@ wasMobile = nowMobile; const box = document.getElementById("monitor-grid"); if (box && lastMonitorRows.length) { - syncMonitorGridColumns(box, lastMonitorRows.length); + syncMonitorGridColumns(box, lastMonitorRows.length + (lastMonitorTotals ? 1 : 0), { + statsFirst: !!lastMonitorTotals, + }); updateMonitorAlertSummary(lastMonitorRows); } }, 120); @@ -2031,7 +2043,8 @@ if (versionChanged || timeChanged || !lastMonitorRows.length) { localBoardVersion = ver; lastMonitorRows = rows; - saveMonitorBoardCache(lastMonitorRows, ts, ver); + lastMonitorTotals = data.totals || null; + saveMonitorBoardCache(lastMonitorRows, ts, ver, lastMonitorTotals); applyMonitorBoardUi(lastMonitorRows, ts, { stale: !!data.aggregating, }); @@ -2092,6 +2105,64 @@ renderMonitorGrid(lastMonitorRows); } + function pnlSigned(v, decimals) { + const n = Number(v); + const d = decimals == null ? 2 : decimals; + if (!Number.isFinite(n)) return "—"; + if (Math.abs(n) < 1e-12) return fmt(0, d); + const abs = fmt(Math.abs(n), d); + return (n > 0 ? "+" : "-") + abs; + } + + function renderMonitorStatsCard(totals) { + const t = totals || {}; + const day = t.trading_day || "—"; + const resetH = t.reset_hour != null ? t.reset_hour : 8; + const winN = Number(t.win_count) || 0; + const lossN = Number(t.loss_count) || 0; + function cell(label, main, sub, valCls) { + return `
+
${esc(label)}
+
${main}
+ ${sub ? `
${sub}
` : ""} +
`; + } + const winSub = + winN > 0 && Number.isFinite(Number(t.win_pnl_u)) + ? `${esc(pnlSigned(t.win_pnl_u, 2))}U` + : "—"; + const lossSub = + lossN > 0 && Number.isFinite(Number(t.loss_pnl_u)) + ? `${esc(pnlSigned(t.loss_pnl_u, 2))}U` + : "—"; + const floatVal = Number(t.float_pnl_u); + return `
+
+
+
+
今日统计
+
+
交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日
+
+
+
+
+ ${cell("今日开仓", String(Number(t.open_count) || 0), "含未平", "")} + ${cell("今日平仓", String(Number(t.closed_count) || 0), "", "")} + ${cell("持有仓位", String(Number(t.open_position_count) || 0), "", "")} + ${cell("盈利", String(winN), winSub, "pnl-pos")} + ${cell("亏损", String(lossN), lossSub, lossN > 0 ? "pnl-neg" : "")} + ${cell( + "总浮盈亏", + esc(pnlSigned(floatVal, 2)) + "U", + "", + pnlCls(floatVal) + )} +
+
+
`; + } + function renderMonitorGrid(rows) { const box = document.getElementById("monitor-grid"); const fs = document.getElementById("exchange-fullscreen"); @@ -2102,17 +2173,27 @@ } const mobileTiles = isMobileLayout() && !expandedExchangeId; const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows; + const showStatsCard = !expandedExchangeId; box.classList.toggle("grid-monitor-tiles", mobileTiles); + box.classList.toggle("grid-monitor-2x2", showStatsCard && !mobileTiles); + box.classList.toggle("grid-monitor-with-stats", showStatsCard && mobileTiles); try { - box.innerHTML = + const statsHtml = showStatsCard ? renderMonitorStatsCard(lastMonitorTotals) : ""; + const cardsHtml = displayRows .map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r))) - .join("") || '
无已启用账户
'; + .join("") || (showStatsCard ? "" : '
无已启用账户
'); + box.innerHTML = statsHtml + cardsHtml; + if (showStatsCard && !cardsHtml && !statsHtml) { + box.innerHTML = '
无已启用账户
'; + } } catch (err) { console.error("renderMonitorGrid", err); box.innerHTML = `
监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
`; } - syncMonitorGridColumns(box, displayRows.length); + syncMonitorGridColumns(box, displayRows.length + (showStatsCard ? 1 : 0), { + statsFirst: showStatsCard, + }); bindMonitorInteractions(box); if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) { TimeCloseUI.tickLocalCountdowns(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 0ec359a..67e6c59 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -1116,6 +1116,6 @@ - + diff --git a/tests/test_hub_monitor_totals_lib.py b/tests/test_hub_monitor_totals_lib.py new file mode 100644 index 0000000..702eefe --- /dev/null +++ b/tests/test_hub_monitor_totals_lib.py @@ -0,0 +1,55 @@ +from lib.hub.hub_monitor_totals_lib import aggregate_monitor_board_totals + + +def test_aggregate_monitor_board_totals_sums_rows(): + rows = [ + { + "day_stats": { + "ok": True, + "opens_today": 2, + "trade_stats": { + "closed_count": 1, + "win_count": 1, + "loss_count": 0, + "win_pnl_u": 5.5, + "loss_pnl_u": 0, + }, + }, + "agent": {"positions": [{"contracts": 1}], "total_unrealized_pnl": 1.2}, + }, + { + "day_stats": { + "ok": True, + "opens_today": 1, + "trade_stats": { + "closed_count": 2, + "win_count": 0, + "loss_count": 2, + "win_pnl_u": 0, + "loss_pnl_u": -3.0, + }, + }, + "agent": {"positions": [], "total_unrealized_pnl": 0}, + }, + ] + out = aggregate_monitor_board_totals(rows, trading_day="2026-07-04", reset_hour=8) + assert out["open_count"] == 3 + assert out["closed_count"] == 3 + assert out["win_count"] == 1 + assert out["loss_count"] == 2 + assert out["win_pnl_u"] == 5.5 + assert out["loss_pnl_u"] == -3.0 + assert out["open_position_count"] == 1 + assert out["float_pnl_u"] == 1.2 + + +def test_summarize_trades_win_loss_amounts(): + from lib.hub.hub_trades_lib import summarize_trades + + stats = summarize_trades( + [{"pnl_amount": 2.5}, {"pnl_amount": -1.0}, {"pnl_amount": 0}] + ) + assert stats["win_count"] == 1 + assert stats["loss_count"] == 1 + assert stats["win_pnl_u"] == 2.5 + assert stats["loss_pnl_u"] == -1.0