feat: 监控区 2x2 布局与左上今日统计卡
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -630,6 +630,7 @@ def register_hub_routes(app):
|
|||||||
fetch_trades_for_trading_day,
|
fetch_trades_for_trading_day,
|
||||||
summarize_trades,
|
summarize_trades,
|
||||||
)
|
)
|
||||||
|
from lib.trade.daily_open_limit_lib import count_opens_for_trading_day
|
||||||
|
|
||||||
c = _ctx()
|
c = _ctx()
|
||||||
get_db = c.get("get_db")
|
get_db = c.get("get_db")
|
||||||
@@ -651,6 +652,7 @@ def register_hub_routes(app):
|
|||||||
row_to_dict_fn=c.get("row_to_dict"),
|
row_to_dict_fn=c.get("row_to_dict"),
|
||||||
reset_hour=reset_hour,
|
reset_hour=reset_hour,
|
||||||
)
|
)
|
||||||
|
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
stats = summarize_trades(trades)
|
stats = summarize_trades(trades)
|
||||||
@@ -659,6 +661,7 @@ def register_hub_routes(app):
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"trading_day": trading_day,
|
"trading_day": trading_day,
|
||||||
"trading_day_reset_hour": reset_hour,
|
"trading_day_reset_hour": reset_hour,
|
||||||
|
"opens_today": opens_today,
|
||||||
"trades": trades,
|
"trades": trades,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -616,6 +616,8 @@ def fetch_trades_for_archive(
|
|||||||
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
||||||
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
|
"""单笔列表 → 笔数 / 盈亏 / 胜败统计。"""
|
||||||
total_pnl = 0.0
|
total_pnl = 0.0
|
||||||
|
win_pnl = 0.0
|
||||||
|
loss_pnl = 0.0
|
||||||
win = loss = flat = 0
|
win = loss = flat = 0
|
||||||
for t in trades or []:
|
for t in trades or []:
|
||||||
try:
|
try:
|
||||||
@@ -625,8 +627,10 @@ def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
|||||||
total_pnl += pnl
|
total_pnl += pnl
|
||||||
if pnl > 1e-9:
|
if pnl > 1e-9:
|
||||||
win += 1
|
win += 1
|
||||||
|
win_pnl += pnl
|
||||||
elif pnl < -1e-9:
|
elif pnl < -1e-9:
|
||||||
loss += 1
|
loss += 1
|
||||||
|
loss_pnl += pnl
|
||||||
else:
|
else:
|
||||||
flat += 1
|
flat += 1
|
||||||
return {
|
return {
|
||||||
@@ -635,4 +639,6 @@ def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
|||||||
"loss_count": loss,
|
"loss_count": loss,
|
||||||
"flat_count": flat,
|
"flat_count": flat,
|
||||||
"total_pnl_u": round(total_pnl, 4),
|
"total_pnl_u": round(total_pnl, 4),
|
||||||
|
"win_pnl_u": round(win_pnl, 4),
|
||||||
|
"loss_pnl_u": round(loss_pnl, 4),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||||||
if str(_REPO_ROOT) not in sys.path:
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_REPO_ROOT))
|
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 (
|
from lib.hub.hub_order_sync_lib import (
|
||||||
cond_order_role,
|
cond_order_role,
|
||||||
dedupe_conditional_orders_by_role,
|
dedupe_conditional_orders_by_role,
|
||||||
@@ -1493,13 +1495,19 @@ async def _fetch_flask_json(
|
|||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
data=None,
|
data=None,
|
||||||
json_body: dict | None = None,
|
json_body: dict | None = None,
|
||||||
|
params: dict | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
base = (ex.get("flask_url") or "").rstrip("/")
|
base = (ex.get("flask_url") or "").rstrip("/")
|
||||||
if not base:
|
if not base:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
if method == "GET":
|
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:
|
else:
|
||||||
headers = {**_hub_headers(), "Content-Type": "application/json"}
|
headers = {**_hub_headers(), "Content-Type": "application/json"}
|
||||||
if json_body is not None:
|
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(
|
async def _fetch_exchange_flask_bundle(
|
||||||
client: httpx.AsyncClient, ex: dict
|
client: httpx.AsyncClient, ex: dict, *, trading_day: str | None = None
|
||||||
) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None]:
|
) -> tuple[dict | None, dict | None, list | None, dict | None, dict | None, dict | None]:
|
||||||
"""单所 Flask:monitor / meta / price_snapshot / account(有 flask_url 时)并行拉取。"""
|
"""单所 Flask:monitor / meta / price_snapshot / account / trades/today(有 flask_url 时)并行拉取。"""
|
||||||
caps = ex.get("capabilities") or []
|
caps = ex.get("capabilities") or []
|
||||||
tasks = [
|
tasks = [
|
||||||
_fetch_flask_json(client, ex, "/api/hub/monitor"),
|
_fetch_flask_json(client, ex, "/api/hub/monitor"),
|
||||||
_fetch_flask_json(client, ex, "/api/hub/meta"),
|
_fetch_flask_json(client, ex, "/api/hub/meta"),
|
||||||
]
|
]
|
||||||
has_flask = bool((ex.get("flask_url") or "").strip())
|
has_flask = bool((ex.get("flask_url") or "").strip())
|
||||||
|
day = (trading_day or "").strip()
|
||||||
if has_flask:
|
if has_flask:
|
||||||
tasks.extend(
|
tasks.extend(
|
||||||
[
|
[
|
||||||
@@ -2052,11 +2061,21 @@ async def _fetch_exchange_flask_bundle(
|
|||||||
_fetch_flask_json(client, ex, "/api/hub/account"),
|
_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)
|
results = await asyncio.gather(*tasks)
|
||||||
hub_mon = results[0]
|
hub_mon = results[0]
|
||||||
meta = results[1]
|
meta = results[1]
|
||||||
snap = results[2] if has_flask and len(results) > 2 else None
|
snap = results[2] if has_flask and len(results) > 2 else None
|
||||||
account = results[3] if has_flask and len(results) > 3 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
|
key_prices = None
|
||||||
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
||||||
if want_prices and isinstance(snap, dict):
|
if want_prices and isinstance(snap, dict):
|
||||||
@@ -2067,14 +2086,34 @@ async def _fetch_exchange_flask_bundle(
|
|||||||
key_prices,
|
key_prices,
|
||||||
snap if isinstance(snap, dict) else None,
|
snap if isinstance(snap, dict) else None,
|
||||||
account if isinstance(account, 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(
|
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:
|
) -> dict:
|
||||||
hub_mon, meta, key_prices, snap, account = await _fetch_exchange_flask_bundle(
|
hub_mon, meta, key_prices, snap, account, trades_today = await _fetch_exchange_flask_bundle(
|
||||||
client, ex
|
client, ex, trading_day=trading_day
|
||||||
)
|
)
|
||||||
if isinstance(hub_mon, dict):
|
if isinstance(hub_mon, dict):
|
||||||
_merge_flask_order_price_fields(hub_mon, snap)
|
_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,
|
"trading_usdt": account.get("trading_usdt") if acct_ok else None,
|
||||||
"available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None,
|
"available_trading_usdt": account.get("available_trading_usdt") if acct_ok else None,
|
||||||
"account_ok": acct_ok,
|
"account_ok": acct_ok,
|
||||||
|
"day_stats": _day_stats_from_trades_body(trades_today),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _build_monitor_board_payload() -> dict:
|
async def _build_monitor_board_payload() -> dict:
|
||||||
exchanges = enabled_exchanges()
|
exchanges = enabled_exchanges()
|
||||||
|
reset_hour = _trading_day_reset_hour()
|
||||||
|
trading_day = current_trading_day(reset_hour=reset_hour)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
agent_rows = await asyncio.gather(
|
agent_rows = await asyncio.gather(
|
||||||
*[_fetch_agent_status(client, ex) for ex in exchanges]
|
*[_fetch_agent_status(client, ex) for ex in exchanges]
|
||||||
)
|
)
|
||||||
out = await asyncio.gather(
|
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)
|
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 {
|
return {
|
||||||
"rows": list(out),
|
"rows": rows,
|
||||||
|
"totals": totals,
|
||||||
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class MonitorBoardStore:
|
|||||||
"ok": p.get("ok", True) if self.payload else False,
|
"ok": p.get("ok", True) if self.payload else False,
|
||||||
"board_version": self.version,
|
"board_version": self.version,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
|
"totals": p.get("totals") if isinstance(p.get("totals"), dict) else None,
|
||||||
"updated_at": p.get("updated_at"),
|
"updated_at": p.get("updated_at"),
|
||||||
"aggregating": self.aggregating,
|
"aggregating": self.aggregating,
|
||||||
"error": self.last_error or p.get("error"),
|
"error": self.last_error or p.get("error"),
|
||||||
|
|||||||
@@ -1153,7 +1153,59 @@ html[data-theme="light"] .host-metric-bar {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
/* 列数由 app.js syncMonitorGridColumns 按卡片数量设置 */
|
/* 列数由 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));
|
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 {
|
.card-expand-zone {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
}
|
}
|
||||||
let tpslPending = null;
|
let tpslPending = null;
|
||||||
let lastMonitorRows = [];
|
let lastMonitorRows = [];
|
||||||
|
let lastMonitorTotals = null;
|
||||||
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
|
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
|
||||||
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
|
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
|
||||||
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||||
@@ -1307,15 +1308,16 @@
|
|||||||
}, 12000);
|
}, 12000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMonitorBoardCache(rows, updatedAt, boardVersion) {
|
function saveMonitorBoardCache(rows, updatedAt, boardVersion, totals) {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
HUB_MONITOR_BOARD_CACHE_KEY,
|
HUB_MONITOR_BOARD_CACHE_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 2,
|
||||||
board_version: boardVersion != null ? boardVersion : localBoardVersion,
|
board_version: boardVersion != null ? boardVersion : localBoardVersion,
|
||||||
updated_at: updatedAt || "",
|
updated_at: updatedAt || "",
|
||||||
rows: rows || [],
|
rows: rows || [],
|
||||||
|
totals: totals || null,
|
||||||
saved_at: Date.now(),
|
saved_at: Date.now(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1343,6 +1345,7 @@
|
|||||||
const cached = loadMonitorBoardFromCache();
|
const cached = loadMonitorBoardFromCache();
|
||||||
if (!cached) return false;
|
if (!cached) return false;
|
||||||
lastMonitorRows = cached.rows;
|
lastMonitorRows = cached.rows;
|
||||||
|
lastMonitorTotals = cached.totals || null;
|
||||||
lastMonitorBoardUpdatedAt = cached.updated_at || "";
|
lastMonitorBoardUpdatedAt = cached.updated_at || "";
|
||||||
localBoardVersion = 0;
|
localBoardVersion = 0;
|
||||||
applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true });
|
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>`;
|
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 列瓦片 */
|
/** 监控卡片列数:桌面 2×2(统计+三所);手机 2 列瓦片 */
|
||||||
function syncMonitorGridColumns(gridEl, count) {
|
function syncMonitorGridColumns(gridEl, itemCount, opts) {
|
||||||
if (!gridEl) return;
|
if (!gridEl) return;
|
||||||
|
const options = opts || {};
|
||||||
if (isMobileLayout()) {
|
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))";
|
gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cols = 3;
|
let cols = 3;
|
||||||
if (count <= 1) cols = 1;
|
if (itemCount <= 1) cols = 1;
|
||||||
else if (count === 2) cols = 2;
|
else if (itemCount === 2) cols = 2;
|
||||||
else if (count === 3) cols = 3;
|
else if (itemCount === 3) cols = 3;
|
||||||
else if (count === 4) cols = 2;
|
else if (itemCount === 4) cols = 2;
|
||||||
else cols = 3;
|
else cols = 3;
|
||||||
gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
|
||||||
}
|
}
|
||||||
@@ -1800,7 +1810,9 @@
|
|||||||
wasMobile = nowMobile;
|
wasMobile = nowMobile;
|
||||||
const box = document.getElementById("monitor-grid");
|
const box = document.getElementById("monitor-grid");
|
||||||
if (box && lastMonitorRows.length) {
|
if (box && lastMonitorRows.length) {
|
||||||
syncMonitorGridColumns(box, lastMonitorRows.length);
|
syncMonitorGridColumns(box, lastMonitorRows.length + (lastMonitorTotals ? 1 : 0), {
|
||||||
|
statsFirst: !!lastMonitorTotals,
|
||||||
|
});
|
||||||
updateMonitorAlertSummary(lastMonitorRows);
|
updateMonitorAlertSummary(lastMonitorRows);
|
||||||
}
|
}
|
||||||
}, 120);
|
}, 120);
|
||||||
@@ -2031,7 +2043,8 @@
|
|||||||
if (versionChanged || timeChanged || !lastMonitorRows.length) {
|
if (versionChanged || timeChanged || !lastMonitorRows.length) {
|
||||||
localBoardVersion = ver;
|
localBoardVersion = ver;
|
||||||
lastMonitorRows = rows;
|
lastMonitorRows = rows;
|
||||||
saveMonitorBoardCache(lastMonitorRows, ts, ver);
|
lastMonitorTotals = data.totals || null;
|
||||||
|
saveMonitorBoardCache(lastMonitorRows, ts, ver, lastMonitorTotals);
|
||||||
applyMonitorBoardUi(lastMonitorRows, ts, {
|
applyMonitorBoardUi(lastMonitorRows, ts, {
|
||||||
stale: !!data.aggregating,
|
stale: !!data.aggregating,
|
||||||
});
|
});
|
||||||
@@ -2092,6 +2105,64 @@
|
|||||||
renderMonitorGrid(lastMonitorRows);
|
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) {
|
function renderMonitorGrid(rows) {
|
||||||
const box = document.getElementById("monitor-grid");
|
const box = document.getElementById("monitor-grid");
|
||||||
const fs = document.getElementById("exchange-fullscreen");
|
const fs = document.getElementById("exchange-fullscreen");
|
||||||
@@ -2102,17 +2173,27 @@
|
|||||||
}
|
}
|
||||||
const mobileTiles = isMobileLayout() && !expandedExchangeId;
|
const mobileTiles = isMobileLayout() && !expandedExchangeId;
|
||||||
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
|
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
|
||||||
|
const showStatsCard = !expandedExchangeId;
|
||||||
box.classList.toggle("grid-monitor-tiles", mobileTiles);
|
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 {
|
try {
|
||||||
box.innerHTML =
|
const statsHtml = showStatsCard ? renderMonitorStatsCard(lastMonitorTotals) : "";
|
||||||
|
const cardsHtml =
|
||||||
displayRows
|
displayRows
|
||||||
.map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r)))
|
.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) {
|
} catch (err) {
|
||||||
console.error("renderMonitorGrid", err);
|
console.error("renderMonitorGrid", err);
|
||||||
box.innerHTML = `<div class="err">监控区渲染失败:${esc(String(err && err.message ? err.message : err))}</div>`;
|
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);
|
bindMonitorInteractions(box);
|
||||||
if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) {
|
if (window.TimeCloseUI && TimeCloseUI.tickLocalCountdowns) {
|
||||||
TimeCloseUI.tickLocalCountdowns();
|
TimeCloseUI.tickLocalCountdowns();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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'" />
|
<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>
|
<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/trade_stats_calendar.css?v=3" />
|
||||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
<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/ai_review_render.js?v=3"></script>
|
||||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||||
<script src="/assets/backup.js?v=1"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user