feat: 监控区 2x2 布局与左上今日统计卡

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-04 23:10:32 +08:00
parent eb975b0133
commit 9deb58a38a
9 changed files with 362 additions and 24 deletions
+56 -9
View File
@@ -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]:
"""单所 Flaskmonitor / 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]:
"""单所 Flaskmonitor / 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"),
}
+1
View File
@@ -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"),
+52
View File
@@ -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 {
+94 -13
View File
@@ -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 = `<span class="mas-item mas-ok">正常 ${ok}</span><span class="mas-sep">·</span><span class="mas-item mas-warn">关注 ${warn}</span><span class="mas-sep">·</span><span class="mas-item mas-err">异常 ${err}</span>`;
}
/** 监控卡片列数:桌面 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 `<div class="monitor-stat-cell">
<div class="monitor-stat-label">${esc(label)}</div>
<div class="monitor-stat-value ${valCls || ""}">${main}</div>
${sub ? `<div class="monitor-stat-sub">${sub}</div>` : ""}
</div>`;
}
const winSub =
winN > 0 && Number.isFinite(Number(t.win_pnl_u))
? `<span class="${pnlCls(t.win_pnl_u)}">${esc(pnlSigned(t.win_pnl_u, 2))}U</span>`
: "—";
const lossSub =
lossN > 0 && Number.isFinite(Number(t.loss_pnl_u))
? `<span class="${pnlCls(t.loss_pnl_u)}">${esc(pnlSigned(t.loss_pnl_u, 2))}U</span>`
: "—";
const floatVal = Number(t.float_pnl_u);
return `<div class="card card-online monitor-stats-card" data-monitor-stats="1">
<div class="card-head">
<div>
<div class="card-title-row">
<div class="card-title">今日统计</div>
</div>
<div class="card-sub">交易日 ${esc(day)} · 北京时间 ${esc(String(resetH))}:00 切日</div>
</div>
</div>
<div class="card-body">
<div class="monitor-stats-grid">
${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)
)}
</div>
</div>
</div>`;
}
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("") || '<div class="err">无已启用账户</div>';
.join("") || (showStatsCard ? "" : '<div class="err">无已启用账户</div>');
box.innerHTML = statsHtml + cardsHtml;
if (showStatsCard && !cardsHtml && !statsHtml) {
box.innerHTML = '<div class="err">无已启用账户</div>';
}
} catch (err) {
console.error("renderMonitorGrid", err);
box.innerHTML = `<div class="err">监控区渲染失败:${esc(String(err && err.message ? err.message : err))}</div>`;
}
syncMonitorGridColumns(box, displayRows.length);
syncMonitorGridColumns(box, displayRows.length + (showStatsCard ? 1 : 0), {
statsFirst: showStatsCard,
});
bindMonitorInteractions(box);
if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) {
TimeCloseUI.tickLocalCountdowns();
+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=20260614-plan-detail" />
<link rel="stylesheet" href="/assets/app.css?v=20260704-monitor-stats" />
<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>
@@ -1116,6 +1116,6 @@
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260614-instance-nav"></script>
<script src="/assets/app.js?v=20260704-monitor-stats"></script>
</body>
</html>