fix(live): 监控页三修——①收益率改首快照基线(与列表同口径,治共用QMT账户下cap兜底的假900%)②组合实盘成交落库_sync_trades(轮询broker当日成交→live_trades,此前完全没人写成交表)③updated_at北京时间显示+策略参数JSON改表格 [vps]
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user