diff --git a/frontend/src/views/live/Monitor.vue b/frontend/src/views/live/Monitor.vue index 95162bc..4d940e7 100644 --- a/frontend/src/views/live/Monitor.vue +++ b/frontend/src/views/live/Monitor.vue @@ -47,21 +47,27 @@ const settingParsed = computed>(() => { }) const totalReturnPct = computed(() => { - const total = balance.value.total - const cap = account.value?.initial_capital - if (total == null || !cap) return null - return (total - cap) / cap + // 后端算好的首快照基线收益率(与列表页同口径)。 + // 不再用 initial_capital 兜底:共用 QMT 账户时账户总额≠实例资金 → 假 900%。 + const r = account.value?.total_return + if (r != null && Number.isFinite(r)) return r + return null }) +/** UTC ISO(库内存的是 +00:00) → 北京时间显示 */ +function fmtTime(v: string | null | undefined): string { + if (!v) return '—' + const d = new Date(v) + if (Number.isNaN(d.getTime())) return v + return new Date(d.getTime() + 8 * 3600 * 1000) + .toISOString().slice(0, 19).replace('T', ' ') +} + const todayStr = new Date().toISOString().slice(0, 10) const todayTrades = computed(() => trades.value.filter((t) => String(t.traded_at).slice(0, 10) === todayStr), ) -function orDash(v: string | null | undefined): string { - return v && v !== 'None' ? v : '—' -} - function num(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—' return Math.round(v).toLocaleString('zh-CN') @@ -191,7 +197,7 @@ async function onStop(): Promise {
上次更新 - {{ orDash(status?.updated_at ?? account?.updated_at) }} + {{ fmtTime(status?.updated_at ?? account?.updated_at) }}
@@ -231,7 +237,9 @@ async function onStop(): Promise { - + + + @@ -278,7 +286,11 @@ async function onStop(): Promise { -
{{ JSON.stringify(settingParsed, null, 2) }}
+ + {{ typeof v === 'object' ? JSON.stringify(v) : String(v) }} +
@@ -312,18 +324,6 @@ async function onStop(): Promise { .metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; } .metric-value { font-size: 22px; font-weight: 700; color: var(--text); } -.setting-pre { - background: var(--bg); - border: 1px solid var(--border-2); - border-radius: var(--r-sm); - padding: 10px 14px; - margin: 0; - font-size: 12px; - color: var(--text-2); - white-space: pre-wrap; - word-break: break-all; -} - @media (max-width: 1200px) { .stat-row { grid-template-columns: repeat(3, 1fr); } } diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index 3dd46a1..14ce5f3 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -135,11 +135,22 @@ def list_lives(): @router.get("/live/{aid}", dependencies=[Depends(verify_token)]) def get_live(aid: int): - from sanguo_live.persistence import get_account + from sanguo_live.persistence import get_account, get_first_balance, get_last_balance acc = get_account(_db_path["path"], aid) if not acc: raise HTTPException(404, "account not found") + # 收益率与列表页同口径:首快照为基线。监控页此前用 initial_capital 兜底, + # 共用 QMT 账户时 total=1000万 vs cap=100万 → 假 900%(2026-08-14 实况)。 + last = get_last_balance(_db_path["path"], aid) + first = get_first_balance(_db_path["path"], aid) + baseline = (first or {}).get("total") if first else None + acc["latest_equity"] = (last or {}).get("total") if last else None + acc["latest_date"] = (last or {}).get("date") if last else None + if last and baseline: + acc["total_return"] = (last.get("total", 0) - baseline) / baseline + else: + acc["total_return"] = None return acc diff --git a/sanguo_portfolio/runner_live.py b/sanguo_portfolio/runner_live.py index d4c43b8..0d9c056 100644 --- a/sanguo_portfolio/runner_live.py +++ b/sanguo_portfolio/runner_live.py @@ -96,6 +96,54 @@ def _snapshot_once(engine: Any, db: str, account_id: int) -> None: ) +def _sync_trades(engine: Any, db: str, account_id: int) -> None: + """轮询 broker 当日成交 → live_trades(去重 by trade_id)。 + + bullet_trade BrokerBase 无成交回调,组合实盘此前完全没人写 live_trades + (2026-08-14 用户发现"没有成交记录")。QMT 只查当日成交,跨日靠 DB 已存行; + 方向从 get_orders 的 is_buy 映射,查不到留空。 + """ + from sanguo_live.persistence import list_trades, save_trade + + broker = getattr(engine, "broker", None) + if broker is None: + return + try: + trades = broker.get_trades() or [] + except Exception as e: # noqa: BLE001 + logger.warning("[live-trades] 查成交失败 (account=%s): %s", account_id, e) + return + if not trades: + return + known = {str(t.get("vt_tradeid") or "") for t in list_trades(db, account_id)} + side_map: Dict[str, str] = {} + try: + for o in broker.get_orders() or []: + oid = str(o.get("order_id") or "") + if oid and o.get("is_buy") is not None: + side_map[oid] = "buy" if o["is_buy"] else "sell" + except Exception: # noqa: BLE001 - 方向映射失败不阻断成交落库 + pass + for t in trades: + tid = str(t.get("trade_id") or "") + if not tid or tid in known: + continue + save_trade(db, account_id, { + "strategy_name": t.get("strategy_name") or "", + "symbol": t.get("security") or "", + "direction": side_map.get(str(t.get("order_id") or ""), ""), + "offset": "", + "price": float(t.get("price") or 0), + "volume": int(t.get("amount") or 0), + "traded_at": str(t.get("time") or ""), + "vt_tradeid": tid, + }) + logger.info("[live-trades] 成交落库 (account=%s %s %s x%s@%s)", + account_id, t.get("security"), side_map.get( + str(t.get("order_id") or ""), "?"), + t.get("amount"), t.get("price")) + + def _snapshot_loop(engine: Any, db: str, account_id: int, interval_sec: float = 60.0) -> None: """后台线程:定时把 engine 组合快照落库(供 API 读)。 @@ -107,6 +155,7 @@ def _snapshot_loop(engine: Any, db: str, account_id: int, time.sleep(interval_sec) try: _snapshot_once(engine, db, account_id) + _sync_trades(engine, db, account_id) except Exception as e: # noqa: BLE001 logger.warning("[live-snapshot] 落库失败 (account=%s): %s", account_id, e) diff --git a/tests/api/test_portfolio_live.py b/tests/api/test_portfolio_live.py index 3e19a72..2ae2325 100644 --- a/tests/api/test_portfolio_live.py +++ b/tests/api/test_portfolio_live.py @@ -216,3 +216,16 @@ def test_snapshot_once_skips_unsynced_cash(): rows = list_balance(db, 3) assert len(rows) == 1 assert rows[0]["total"] == 10_000_008.51 + + +def test_get_live_return_uses_first_snapshot_baseline(live_db): + """监控页收益率与列表页同口径(首快照基线),不再用 initial_capital 兜底。""" + from sanguo_live.persistence import save_balance + aid = _create_portfolio(live_db) + save_balance(live_db, aid, "2026-08-14 20:00:00", 9_000_000, 1_000_000, + total=10_000_000) + save_balance(live_db, aid, "2026-08-14 21:00:00", 9_100_000, 1_050_000, + total=10_150_000) + acc = rl.get_live(aid) + assert acc["latest_equity"] == 10_150_000 + assert acc["total_return"] == (10_150_000 - 10_000_000) / 10_000_000