diff --git a/frontend/src/api/live.ts b/frontend/src/api/live.ts index 69f69ce..62222a4 100644 --- a/frontend/src/api/live.ts +++ b/frontend/src/api/live.ts @@ -149,7 +149,78 @@ export interface LiveUpdateReq { interval?: string } +export interface LiveUpdateReq { + name?: string + account?: string + vt_symbol?: string + strategy_name?: string + interval?: string + /** B3 预算(initial_capital 含义升级为资金额度) */ + initial_capital?: number +} + export async function updateLive(aid: number, req: LiveUpdateReq): Promise<{ updated: boolean }> { const { data } = await apiClient.put(`/live/${aid}`, req) return data } + +// ===== B3/B4 预算制 + 账户实况 ===== + +/** B3 预算信息(GET /live/budget-info) */ +export interface BudgetInfo { + account: string + /** 快照是否新鲜(10 分钟内);false 时 remaining=null(fail-closed) */ + fresh: boolean + account_cash: number | null + allocated: number + remaining: number | null + snapshot_at?: string | null +} + +export async function getBudgetInfo(account: string): Promise { + const { data } = await apiClient.get('/live/budget-info', { + params: { account }, + }) + return data +} + +/** B4 账户实况持仓行(qmt_account_snapshot.positions JSON) */ +export interface AccountPositionRow { + symbol: string + volume: number + can_use: number + avg_price: number + mv: number +} + +/** B4 账户实况实例分解行 */ +export interface SnapshotInstanceRow { + id: number + name: string + status: string + initial_capital: number + cash: number | null + market_value: number | null + equity: number | null +} + +/** B4 账户实况(GET /live/account-snapshot) */ +export interface AccountSnapshotView { + account: string + fresh: boolean + cash: number | null + market_value: number | null + total: number | null + positions: AccountPositionRow[] + updated_at?: string | null + instances: SnapshotInstanceRow[] + instance_mv_total: number + unattributed_mv: number | null +} + +export async function getAccountSnapshot(account: string): Promise { + const { data } = await apiClient.get('/live/account-snapshot', { + params: { account }, + }) + return data +} diff --git a/frontend/src/views/live/List.vue b/frontend/src/views/live/List.vue index d73cc6a..098f91f 100644 --- a/frontend/src/views/live/List.vue +++ b/frontend/src/views/live/List.vue @@ -6,8 +6,8 @@ import { poolLabel } from '@/constants/strategy' import { getInstances } from '@/api/strategy' import { ElMessage, ElMessageBox } from 'element-plus' import { - listLives, startLive, stopLive, deleteLive, updateLive, - type LiveAccount, + listLives, startLive, stopLive, deleteLive, updateLive, getBudgetInfo, + type LiveAccount, type BudgetInfo, } from '@/api/live' const router = useRouter() @@ -43,11 +43,40 @@ async function refresh(): Promise { async function refreshSilent(): Promise { try { accounts.value = await listLives() + void loadBudgets() // B4 占用率横幅(失败静默——横幅缺席不阻塞列表) } catch (e: unknown) { ElMessage.error(e instanceof Error ? e.message : '加载失败') } } +// ===== B4 预算占用率(每 QMT 账号一条;存量超限亮黄条) ===== +const budgetRows = ref>([]) + +async function loadBudgets(): Promise { + const accs = [...new Set(accounts.value.map((a) => a.account).filter(Boolean))] + const rows: typeof budgetRows.value = [] + for (const acc of accs) { + try { + const b: BudgetInfo = await getBudgetInfo(acc) + const cash = b.fresh ? b.account_cash : null + const over = b.fresh && b.account_cash != null && b.allocated > b.account_cash + const rawPct = b.fresh && b.account_cash && b.account_cash > 0 + ? (b.allocated / b.account_cash) * 100 : null + rows.push({ + account: acc, cash, allocated: b.allocated, over, fresh: b.fresh, + pctText: rawPct != null ? rawPct.toFixed(0) + '%' : '—', + barPct: rawPct != null ? Math.min(rawPct, 100) : 0, + }) + } catch { + /* 单账户失败跳过 */ + } + } + budgetRows.value = rows +} + const filtered = computed(() => accounts.value.filter((a) => { if (instFilter.value != null && (a as { instance_id?: number | null }).instance_id !== instFilter.value) return false @@ -244,6 +273,26 @@ async function onStop(a: LiveAccount): Promise {
已停止
{{ stats.stopped }}
+ + + + +
@@ -360,6 +409,12 @@ async function onStop(a: LiveAccount): Promise { .live-list { display: flex; flex-direction: column; gap: 16px; } .stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } + +/* B4 预算占用率横幅 */ +.budget-alert { padding-top: 6px; padding-bottom: 6px; } +.budget-title { font-size: 13px; } +.budget-over-text { color: var(--el-color-warning, #e6a23c); font-weight: 600; } +.budget-progress { width: 320px; margin-left: 12px; display: inline-flex; vertical-align: middle; } .stat-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 14px 16px; } .stat-label { font-size: 12px; color: var(--text-3); } .stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; color: var(--text); } diff --git a/frontend/src/views/live/Monitor.vue b/frontend/src/views/live/Monitor.vue index 4d940e7..9ebc633 100644 --- a/frontend/src/views/live/Monitor.vue +++ b/frontend/src/views/live/Monitor.vue @@ -4,8 +4,9 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { getLive, getLivePositions, getLiveTrades, getLiveAccountBalance, getLiveStatus, - startLive, stopLive, - type LiveAccount, type LivePosition, type LiveTrade, type LiveBalance, type LiveStatus, + startLive, stopLive, getAccountSnapshot, + type LiveAccount, type LivePosition, type LiveTrade, type LiveBalance, + type LiveStatus, type AccountSnapshotView, } from '@/api/live' const route = useRoute() @@ -17,6 +18,8 @@ const status = ref(null) const balance = ref({}) const positions = ref([]) const trades = ref([]) +// B4 第三层:全账户实况(共享 QMT 账户真相,与本实例账本分开看) +const snap = ref(null) const actionLoading = ref(false) let timer: ReturnType | null = null @@ -92,6 +95,10 @@ async function load(): Promise { balance.value = bal ?? {} positions.value = pos trades.value = tr + // 第三层:按本实例的 QMT 账号拉全账户实况 + if (acc.account) { + snap.value = await getAccountSnapshot(acc.account).catch(() => null) + } } catch (e: unknown) { ElMessage.error(e instanceof Error ? e.message : '加载失败') } finally { @@ -187,7 +194,7 @@ async function onStop(): Promise { {{ account?.interval ?? '—' }} / {{ num(account?.initial_capital) }}
- 最新账户总额 + 本实例净值 {{ num(balance.total) }} {{ balance.date ?? '—' }}
@@ -201,12 +208,12 @@ async function onStop(): Promise { - +
-
现金
+
本策略现金
{{ num(balance.cash) }}
@@ -214,7 +221,7 @@ async function onStop(): Promise {
-
市值
+
本策略市值
{{ num(balance.market_value) }}
@@ -222,7 +229,7 @@ async function onStop(): Promise {
-
总资产
+
本策略净值
{{ num(balance.total) }}
@@ -231,7 +238,7 @@ async function onStop(): Promise { - + @@ -243,9 +250,9 @@ async function onStop(): Promise { - + - + @@ -265,7 +272,7 @@ async function onStop(): Promise { - + @@ -283,6 +290,79 @@ async function onStop(): Promise { + + + + + + + @@ -324,6 +404,20 @@ 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); } +/* B4 账户实况卡 */ +.acct-card :deep(.el-card__header) { display: flex; align-items: center; justify-content: space-between; } +.acct-fresh { font-size: 12px; } +.acct-metrics { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--sp-3); margin-bottom: 14px; } +.acct-metric { + background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-md); + padding: 10px 12px; +} +.acct-metric .metric-value { font-size: 17px; } +.acct-sub { font-size: 11px; margin-top: 4px; } +.acct-sub-title { font-size: 12px; color: var(--text-3); margin: 12px 0 6px; letter-spacing: 0.3px; } +.warn-num { color: var(--el-color-warning, #e6a23c); } +@media (max-width: 1200px) { .acct-metrics { grid-template-columns: repeat(3, 1fr); } } + @media (max-width: 1200px) { .stat-row { grid-template-columns: repeat(3, 1fr); } } diff --git a/frontend/src/views/live/New.vue b/frontend/src/views/live/New.vue index a8d10dd..76fbedf 100644 --- a/frontend/src/views/live/New.vue +++ b/frontend/src/views/live/New.vue @@ -2,7 +2,7 @@ import { ref, computed, watch, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { createLive, type LiveCreateRequest } from '@/api/live' +import { createLive, getBudgetInfo, type LiveCreateRequest, type BudgetInfo } from '@/api/live' import { apiClient } from '@/api/client' import { getInstances, type Instance } from '@/api/strategy' import { INTERVAL_OPTIONS } from '@/constants/intervals' @@ -92,6 +92,51 @@ const strategyClassOptions = [ { value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' }, ] +// ===== B3/B4 预算制:默认值=剩余可分配 + 占用率条 + 超限禁提交 ===== +const budget = ref(null) +const budgetTouched = ref(false) // 用户手动改过预算 → 不再跟随默认值 + +async function loadBudget(): Promise { + try { + budget.value = await getBudgetInfo(form.value.account.trim()) + // 默认值=剩余可分配(用户未手动改过才跟随) + if (!budgetTouched.value && budget.value.fresh && budget.value.remaining != null) { + form.value.initial_capital = Math.max(10000, Math.floor(budget.value.remaining)) + } + } catch { + budget.value = null // 拉不到不阻塞表单,提交时后端 fail-closed 兜底 + } +} + +/** 账号变化 → 重新拉预算 */ +watch(() => form.value.account, () => { void loadBudget() }) +onMounted(() => { void loadBudget() }) + +const overBudget = computed(() => + budget.value?.fresh === true + && budget.value.remaining != null + && form.value.initial_capital > budget.value.remaining, +) +const budgetPct = computed(() => { + const b = budget.value + if (!b?.fresh || !b.account_cash || b.account_cash <= 0) return null + // 占用率=(已分配+本次)/现金,>100% 红 + return Math.min(((b.allocated + form.value.initial_capital) / b.account_cash) * 100, 100) +}) +const budgetPctRaw = computed(() => { + const b = budget.value + if (!b?.fresh || !b.account_cash || b.account_cash <= 0) return null + return ((b.allocated + form.value.initial_capital) / b.account_cash) * 100 +}) +const budgetOverPct = computed(() => + budgetPctRaw.value != null && budgetPctRaw.value > 100, +) + +function num(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—' + return Math.round(v).toLocaleString('zh-CN') +} + async function applyInstance(instId: number | string): Promise { try { const [{ data: ir }, { data: fr }] = await Promise.all([ @@ -161,6 +206,15 @@ async function onSubmit(): Promise { ElMessage.warning('请选择组合策略') return } + // B3 预算前置:超限/快照不可用禁提交(后端同款校验兜底) + if (budget.value && !budget.value.fresh) { + ElMessage.error('账户快照不可用(未采集或超过10分钟),稍后再试') + return + } + if (overBudget.value) { + ElMessage.error(`预算超限:剩余可分配 ${budget.value?.remaining?.toLocaleString('zh-CN') ?? '—'} 元`) + return + } if (!isPortfolio.value && !form.value.strategy_name.trim()) { // 引擎实例名是技术标识:空则自动生成(策略类_代码),不再强制用户填 const digits = (form.value.vt_symbol || '').replace(/\D/g, '') @@ -229,9 +283,32 @@ async function onSubmit(): Promise { QMT 账号 - - - 单位:元 + + + 元 · 共享账户资金额度,默认=剩余可分配,可改小 + +
+
+ 账户现金 {{ num(budget.account_cash) }} + 已分配 {{ num(budget.allocated) }} + 本次后占用 {{ num(budget.allocated + form.initial_capital) }} +
+ +
+ 超出剩余可分配 {{ num(form.initial_capital - (budget.remaining ?? 0)) }} 元,请调低预算 +
+
+
@@ -382,7 +459,11 @@ async function onSubmit(): Promise {
- + 创建实盘实例 取消 @@ -396,6 +477,14 @@ async function onSubmit(): Promise { .form-hint { margin-left: 10px; } .submit-bar { padding: 4px 0; display: flex; gap: 12px; } +.budget-bar-wrap { width: 480px; margin-top: 8px; } +.budget-line { + display: flex; justify-content: space-between; + font-size: 12px; color: var(--text-3); margin-bottom: 4px; +} +.over-text { color: var(--el-color-danger, #f56c6c); font-weight: 600; } +.budget-over { font-size: 12px; color: var(--el-color-danger, #f56c6c); margin-top: 4px; } + .seg-label { font-size: 12px; color: var(--text-3); margin-bottom: 8px; } .seg-row { display: flex; gap: 12px; } .seg-card { diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index cf8dec9..b5b760d 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -258,6 +258,57 @@ def get_budget_info(account: str = ""): return _budget_state(db, account) +@router.get("/live/account-snapshot", dependencies=[Depends(verify_token)]) +def get_account_snapshot_route(account: str = ""): + """账户实况(B4 三层展示第三层):全局快照 + Σ实例市值分解。 + + instances = 同 QMT 账号各实盘实例的最新账本(live_balance 实例视图); + unattributed = 全账户市值 − Σ实例市值(重建后应≈0,大数=遗留仓/手动仓)。 + 快照缺失时 fresh=False、数值字段 None(前端显示『快照不可用』)。 + """ + from sanguo_live.persistence import ( + get_account_snapshot, list_accounts, get_last_balance, + ) + + db = _db_path["path"] + if not db: + raise HTTPException(400, "db 未配置") + acc = (account or "").strip() + snap = get_account_snapshot(db, acc) if acc else None + from sanguo_live.persistence import get_fresh_account_snapshot + fresh = get_fresh_account_snapshot(db, acc) is not None if acc else False + + instances = [] + for row in list_accounts(db): + if (row.get("account") or "").strip() != acc: + continue + last = get_last_balance(db, row["id"]) + instances.append({ + "id": row["id"], "name": row.get("name") or "", + "status": row.get("status") or "", + "initial_capital": float(row.get("initial_capital") or 0), + "cash": (last or {}).get("cash"), + "market_value": (last or {}).get("market_value"), + "equity": (last or {}).get("total"), + }) + instance_mv = sum( + float(i["market_value"] or 0) for i in instances) + snap_mv = float(snap.get("market_value")) if snap else None + return { + "account": acc, + "fresh": fresh, + "cash": (snap or {}).get("cash"), + "market_value": snap_mv, + "total": (snap or {}).get("total"), + "positions": (snap or {}).get("positions") or [], + "updated_at": (snap or {}).get("updated_at"), + "instances": instances, + "instance_mv_total": instance_mv, + "unattributed_mv": (snap_mv - instance_mv + if snap_mv is not None else None), + } + + @router.get("/live/{aid}", dependencies=[Depends(verify_token)]) def get_live(aid: int): from sanguo_live.persistence import get_account, get_first_balance, get_last_balance diff --git a/tests/api/test_live_budget.py b/tests/api/test_live_budget.py index 4045de0..d1dba6e 100644 --- a/tests/api/test_live_budget.py +++ b/tests/api/test_live_budget.py @@ -174,3 +174,56 @@ def test_delete_frees_budget(live_db): aid = _create(live_db, capital=300_000) rl.delete_live(aid) assert rl.get_budget_info("66639661")["remaining"] == 1_000_000 + + +# ---------------- account-snapshot(B4 第三层数据源) ---------------- + +def test_account_snapshot_breakdown(live_db): + """全局快照 + Σ实例分解:unattributed = 全账户市值 − Σ实例市值。""" + lp.upsert_account_snapshot( + live_db, "66639661", cash=400_000.0, market_value=600_000.0, + total=1_000_000.0, + positions=[{"symbol": "600036.SH", "volume": 1000, "can_use": 1000, + "avg_price": 38.0, "mv": 38_000.0}]) + aid = _create(live_db, capital=300_000) + lp.save_balance(live_db, aid, "2026-08-19 15:00:00", + 100_000.0, 200_000.0, 300_000.0) + r = rl.get_account_snapshot_route("66639661") + assert r["fresh"] is True + assert r["cash"] == 400_000.0 + assert r["market_value"] == 600_000.0 + assert len(r["positions"]) == 1 + assert len(r["instances"]) == 1 + assert r["instances"][0]["market_value"] == 200_000.0 + assert r["instance_mv_total"] == 200_000.0 + assert r["unattributed_mv"] == 400_000.0 + + +def test_account_snapshot_no_snapshot_all_none(live_db): + """快照缺失 → fresh=False 数值 None,但实例列表仍返回(有实例无快照)。""" + _snapshot(live_db, cash=1_000_000) + aid = _create(live_db, capital=300_000) # 播种快照后建,再删快照 + with sqlite3.connect(live_db) as conn: + conn.execute("DELETE FROM qmt_account_snapshot") + r = rl.get_account_snapshot_route("66639661") + assert r["fresh"] is False + assert r["cash"] is None + assert r["unattributed_mv"] is None + assert len(r["instances"]) == 1 + + +def test_account_snapshot_filters_other_accounts(live_db): + """实例只归同 QMT 账号——别的账号实例不进分解。""" + _snapshot(live_db, cash=1_000_000) + _snapshot(live_db, account="OTHER", cash=500_000) + aid1 = _create(live_db, capital=100_000) + lp.save_balance(live_db, aid1, "d1", 0, 50_000.0, 50_000.0) + # OTHER 账号的实例 + req = rl.LiveCreateRequest( + account="OTHER", strategy_name="p2", strategy_type="portfolio", + strategy_class="all_weather", initial_capital=100_000) + aid2 = rl.create_live(req)["account_id"] + lp.save_balance(live_db, aid2, "d1", 0, 70_000.0, 70_000.0) + r = rl.get_account_snapshot_route("66639661") + assert [i["id"] for i in r["instances"]] == [aid1] + assert r["instance_mv_total"] == 50_000.0