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]
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user