feat(live): B4前端三层展示+预算表单+账户实况端点(spec§B4)——①新建实盘表单:起始资金升级『实例预算』,默认值=剩余可分配(budget-info,用户改过不跟随),占用率进度条(已分配+本次 vs 现金),超限红条+禁提交,快照不可用警告条(与后端fail-closed双保险)②监控页三层重构:本策略买卖(live_trades归因,今日/全部)/本策略账本+持仓(实例视图,现金/市值/净值语义重命名)/账户实况(新卡:现金/市值/总资产+Σ实例市值+未归因市值=账户市值−Σ实例+实例分解表+QMT全账户持仓表)③列表页:每QMT账号一条占用率横幅,存量超限亮黄条⚠④后端补GET /live/account-snapshot(注册在{aid}前):全局快照+同账号实例最新账本分解+unattributed_mv,快照缺失fresh=False数值None;⑤live.ts补BudgetInfo/AccountSnapshotView类型+updateLive带initial_capital;+3端点测试(分解算术/无快照降级/跨账号过滤);api 164绿;npm run build绿 [vps]
CI/CD / test (push) Successful in 15s
CI/CD / nas-deploy (push) Successful in 1m24s
CI/CD / nas-verify (push) Successful in 15s

This commit is contained in:
2026-08-19 22:26:41 +08:00
parent 5a91be2efd
commit 537fd2d571
6 changed files with 431 additions and 18 deletions
+71
View File
@@ -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<BudgetInfo> {
const { data } = await apiClient.get<BudgetInfo>('/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<AccountSnapshotView> {
const { data } = await apiClient.get<AccountSnapshotView>('/live/account-snapshot', {
params: { account },
})
return data
}
+57 -2
View File
@@ -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<void> {
async function refreshSilent(): Promise<void> {
try {
accounts.value = await listLives()
void loadBudgets() // B4 占用率横幅(失败静默——横幅缺席不阻塞列表)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
}
}
// ===== B4 预算占用率(每 QMT 账号一条;存量超限亮黄条) =====
const budgetRows = ref<Array<{
account: string; cash: number | null; allocated: number
over: boolean; fresh: boolean; pctText: string; barPct: number
}>>([])
async function loadBudgets(): Promise<void> {
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<void> {
<div class="stat-card"><div class="stat-label">已停止</div><div class="stat-value mono muted">{{ stats.stopped }}</div></div>
</div>
<!-- B4 预算占用率( QMT 账号一条;存量超限亮黄条) -->
<el-alert
v-for="b in budgetRows" :key="b.account"
:type="b.over ? 'warning' : 'info'"
:closable="false" class="budget-alert"
>
<template #title>
<span class="budget-title">
QMT 账户 {{ b.account }} · 现金 {{ num(b.cash) }} ·
已分配 {{ num(b.allocated) }}{{ b.pctText }} 占用
<span v-if="b.over" class="budget-over-text"> 超过账户现金</span>
<span v-else-if="!b.fresh" class="muted">· 快照不可用</span>
</span>
<el-progress
:percentage="b.barPct" :stroke-width="8" :show-text="false"
:color="b.over ? '#e6a23c' : '#409eff'" class="budget-progress"
/>
</template>
</el-alert>
<!-- 筛选 -->
<div class="filter-row">
<el-input v-model="kw" placeholder="搜索 名称 / ID / 标的 / 策略" clearable style="width: 280px" />
@@ -360,6 +409,12 @@ async function onStop(a: LiveAccount): Promise<void> {
.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); }
+105 -11
View File
@@ -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<LiveStatus | null>(null)
const balance = ref<LiveBalance>({})
const positions = ref<LivePosition[]>([])
const trades = ref<LiveTrade[]>([])
// B4 :( QMT ,)
const snap = ref<AccountSnapshotView | null>(null)
const actionLoading = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
@@ -92,6 +95,10 @@ async function load(): Promise<void> {
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<void> {
<span class="stat-value mono">{{ account?.interval ?? '—' }} / {{ num(account?.initial_capital) }}</span>
</div>
<div class="stat">
<span class="stat-label">最新账户总额</span>
<span class="stat-label">本实例净值</span>
<span class="stat-value mono">{{ num(balance.total) }}</span>
<span class="stat-sub mono muted">{{ balance.date ?? '—' }}</span>
</div>
@@ -201,12 +208,12 @@ async function onStop(): Promise<void> {
</div>
</div>
<!-- 净值三件套 -->
<!-- 第二层:本策略账本(实例视图预算内自己的现金/市值/持仓) -->
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">现金</div>
<div class="metric-label">本策略现金</div>
<div class="metric-value mono">{{ num(balance.cash) }}</div>
</div>
</el-card>
@@ -214,7 +221,7 @@ async function onStop(): Promise<void> {
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">市值</div>
<div class="metric-label">本策略市值</div>
<div class="metric-value mono">{{ num(balance.market_value) }}</div>
</div>
</el-card>
@@ -222,7 +229,7 @@ async function onStop(): Promise<void> {
<el-col :span="8">
<el-card shadow="never">
<div class="metric">
<div class="metric-label">总资产</div>
<div class="metric-label">本策略净值</div>
<div class="metric-value mono">{{ num(balance.total) }}</div>
</div>
</el-card>
@@ -231,7 +238,7 @@ async function onStop(): Promise<void> {
<!-- 持仓 -->
<el-card shadow="never">
<template #header><span class="section-title">当前持仓</span></template>
<template #header><span class="section-title">本策略持仓(实例账本)</span></template>
<el-table :data="positions" size="small" empty-text="无持仓">
<el-table-column prop="symbol" label="标的" min-width="100" />
<el-table-column prop="volume" label="总持仓" width="100" class-name="num" />
@@ -243,9 +250,9 @@ async function onStop(): Promise<void> {
</el-table>
</el-card>
<!-- 今日成交 -->
<!-- 第一层:本策略买卖(归因只有本实例的成交) -->
<el-card shadow="never">
<template #header><span class="section-title">今日成交</span></template>
<template #header><span class="section-title">本策略今日成交(归因)</span></template>
<el-table :data="todayTrades" size="small" empty-text="今日无成交">
<el-table-column prop="traded_at" label="时间" min-width="160" />
<el-table-column prop="strategy_name" label="策略" min-width="160" />
@@ -265,7 +272,7 @@ async function onStop(): Promise<void> {
<!-- 全部成交(最新在上) -->
<el-card shadow="never">
<template #header><span class="section-title">全部成交记录</span></template>
<template #header><span class="section-title">本策略全部成交(归因,最新在上)</span></template>
<el-table :data="[...trades].reverse()" size="small" empty-text="无成交" max-height="400">
<el-table-column prop="traded_at" label="时间" min-width="160" />
<el-table-column prop="strategy_name" label="策略" min-width="160" />
@@ -283,6 +290,79 @@ async function onStop(): Promise<void> {
</el-table>
</el-card>
<!-- 第三层:账户实况(共享 QMT 账户真相,与本实例账本分开看) -->
<el-card shadow="never" class="acct-card">
<template #header>
<span class="section-title">账户实况(共享 QMT {{ account?.account || '—' }})</span>
<span class="muted acct-fresh">
{{ snap?.fresh ? `快照 ${fmtTime(snap?.updated_at ?? undefined)}` : '快照不可用(>10分钟)' }}
</span>
</template>
<template v-if="snap">
<div class="acct-metrics">
<div class="acct-metric">
<div class="metric-label">账户现金</div>
<div class="metric-value mono">{{ num(snap.cash) }}</div>
</div>
<div class="acct-metric">
<div class="metric-label">账户市值</div>
<div class="metric-value mono">{{ num(snap.market_value) }}</div>
</div>
<div class="acct-metric">
<div class="metric-label">账户总资产</div>
<div class="metric-value mono">{{ num(snap.total) }}</div>
</div>
<div class="acct-metric">
<div class="metric-label">Σ实例市值</div>
<div class="metric-value mono">{{ num(snap.instance_mv_total) }}</div>
<div class="acct-sub muted">{{ snap.instances.length }} 个实例</div>
</div>
<div class="acct-metric">
<div class="metric-label">未归因市值</div>
<div class="metric-value mono" :class="{ 'warn-num': (snap.unattributed_mv ?? 0) > 0 }">
{{ num(snap.unattributed_mv) }}
</div>
<div class="acct-sub muted">= 账户市值 Σ实例(遗留/手动仓)</div>
</div>
</div>
<div class="acct-sub-title">实例分解</div>
<el-table :data="snap.instances" size="small" empty-text="无实例">
<el-table-column prop="id" label="#" width="60" />
<el-table-column prop="name" label="实例" min-width="180" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'running' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="initial_capital" label="预算" width="110" class-name="num">
<template #default="{ row }">{{ num(row.initial_capital) }}</template>
</el-table-column>
<el-table-column prop="cash" label="现金" width="110" class-name="num">
<template #default="{ row }">{{ num(row.cash) }}</template>
</el-table-column>
<el-table-column prop="market_value" label="市值" width="110" class-name="num">
<template #default="{ row }">{{ num(row.market_value) }}</template>
</el-table-column>
<el-table-column prop="equity" label="净值" width="110" class-name="num">
<template #default="{ row }">{{ num(row.equity) }}</template>
</el-table-column>
</el-table>
<div class="acct-sub-title">账户持仓(QMT 全账户)</div>
<el-table :data="snap.positions" size="small" empty-text="账户无持仓" max-height="300">
<el-table-column prop="symbol" label="标的" min-width="110" />
<el-table-column prop="volume" label="持仓" width="100" class-name="num" />
<el-table-column prop="can_use" label="可卖(T+1)" width="100" class-name="num" />
<el-table-column prop="avg_price" label="成本" width="100" class-name="num" />
<el-table-column prop="mv" label="市值" width="120" class-name="num">
<template #default="{ row }">{{ num(row.mv) }}</template>
</el-table-column>
</el-table>
</template>
<el-empty v-else description="账户快照未加载" :image-size="60" />
</el-card>
<!-- 策略参数 -->
<el-card shadow="never">
<template #header><span class="section-title">策略参数</span></template>
@@ -324,6 +404,20 @@ 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); }
/* 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); }
}
+94 -5
View File
@@ -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<BudgetInfo | null>(null)
const budgetTouched = ref(false) //
async function loadBudget(): Promise<void> {
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<void> {
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
@@ -161,6 +206,15 @@ async function onSubmit(): Promise<void> {
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<void> {
<el-input v-model="form.account" placeholder="66639661" style="width: 320px" />
<span class="muted form-hint">QMT 账号</span>
</el-form-item>
<el-form-item label="起始资金">
<el-input-number v-model="form.initial_capital" :min="10000" :step="100000" style="width: 240px" />
<span class="muted form-hint">单位:</span>
<el-form-item label="实例预算">
<el-input-number
v-model="form.initial_capital" :min="10000" :step="100000"
style="width: 240px" @change="budgetTouched = true"
/>
<span class="muted form-hint"> · 共享账户资金额度,默认=剩余可分配,可改小</span>
<!-- B4 占用率条:已分配+本次 vs 账户现金 -->
<div v-if="budget?.fresh" class="budget-bar-wrap">
<div class="budget-line">
<span>账户现金 {{ num(budget.account_cash) }}</span>
<span>已分配 {{ num(budget.allocated) }}</span>
<span :class="{ 'over-text': budgetOverPct }">本次后占用 {{ num(budget.allocated + form.initial_capital) }}</span>
</div>
<el-progress
:percentage="budgetPct ?? 0" :stroke-width="10" :show-text="false"
:color="budgetOverPct ? '#f56c6c' : '#e6a23c'"
/>
<div v-if="overBudget" class="budget-over">
超出剩余可分配 {{ num(form.initial_capital - (budget.remaining ?? 0)) }} ,请调低预算
</div>
</div>
<el-alert
v-else-if="budget && !budget.fresh" type="warning" :closable="false"
title="账户快照不可用(未采集或超过10分钟)——无法校验预算,暂不能创建"
style="width: 480px; margin-top: 6px"
/>
</el-form-item>
<el-form-item label="mini_path">
<el-input v-model="form.mini_path" placeholder="C:\\国金QMT交易端模拟\\userdata_mini" style="width: 480px" />
@@ -382,7 +459,11 @@ async function onSubmit(): Promise<void> {
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="loading" @click="onSubmit">
<el-button
type="primary" size="large" :loading="loading"
:disabled="overBudget || budget?.fresh === false"
@click="onSubmit"
>
创建实盘实例
</el-button>
<el-button size="large" @click="router.push('/live')">取消</el-button>
@@ -396,6 +477,14 @@ async function onSubmit(): Promise<void> {
.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 {
+51
View File
@@ -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
+53
View File
@@ -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