fix(live): 监控页三修——①收益率改首快照基线(与列表同口径,治共用QMT账户下cap兜底的假900%)②组合实盘成交落库_sync_trades(轮询broker当日成交→live_trades,此前完全没人写成交表)③updated_at北京时间显示+策略参数JSON改表格 [vps]
CI/CD / test (push) Successful in 15s
CI/CD / nas-deploy (push) Successful in 42s
CI/CD / nas-verify (push) Successful in 14s

This commit is contained in:
2026-08-14 22:07:30 +08:00
parent f73810a6da
commit f49154266d
4 changed files with 97 additions and 24 deletions
+23 -23
View File
@@ -47,21 +47,27 @@ const settingParsed = computed<Record<string, unknown>>(() => {
})
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<void> {
</div>
<div class="stat">
<span class="stat-label">上次更新</span>
<span class="stat-value num">{{ orDash(status?.updated_at ?? account?.updated_at) }}</span>
<span class="stat-value num">{{ fmtTime(status?.updated_at ?? account?.updated_at) }}</span>
</div>
</div>
@@ -231,7 +237,9 @@ async function onStop(): Promise<void> {
<el-table-column prop="volume" label="总持仓" width="100" class-name="num" />
<el-table-column prop="frozen" label="冻结" width="90" class-name="num" />
<el-table-column prop="avg_price" label="均价" width="100" class-name="num" />
<el-table-column prop="updated_at" label="更新时间" min-width="160" />
<el-table-column prop="updated_at" label="更新时间" min-width="160">
<template #default="{ row }">{{ fmtTime(row.updated_at) }}</template>
</el-table-column>
</el-table>
</el-card>
@@ -278,7 +286,11 @@ async function onStop(): Promise<void> {
<!-- 策略参数 -->
<el-card shadow="never">
<template #header><span class="section-title">策略参数</span></template>
<pre class="setting-pre mono">{{ JSON.stringify(settingParsed, null, 2) }}</pre>
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="(v, k) in settingParsed" :key="k" :label="String(k)"
>{{ typeof v === 'object' ? JSON.stringify(v) : String(v) }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
@@ -312,18 +324,6 @@ async function onStop(): Promise<void> {
.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); }
}
+12 -1
View File
@@ -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
+49
View File
@@ -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)
+13
View File
@@ -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