Files
sanguo_vnpy_v2/frontend/src/views/live/New.vue
T
claude_dev f214e2f0e4
CI/CD / test (push) Failing after 12m2s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped
fix(live): max_pool 全链默认 30→0(0=不限)——08-24巡检定罪MVP限流泄漏生产,选股池=代码序前30只失真 [vps]
根因(策略session 08-24 午休探针实证):runtime/live_strategy.py SANGUO_LIVE_MAX_POOL
默认30经env注入全部实盘+影子+paper实例,_stock_pool截断成份池为「代码序前30只」:
small_cap「全市场最小市值」实际在000001平安银行等30只固定代码里选(平安银行≈3800亿
出现在小市值买入=market_cap开盘NaN排序失效叠bug);momentum每行业RPS只在代码序前
30里排;value 0/30+零委托史同源。注释自曝「MVP验证用」=限流遗留泄漏生产,上线
首日起全部选股失真。

改动(9处默认位一致30→0;语义0=不限,与策略层max_pool>0才截断一致):
- sanguo_portfolio/live_strategy.py 适配器env默认+docstring
- sanguo_live/runner.py _portfolio_env_for(存量DB显式值不篡改,缺列/0→"0")
- sanguo_trader/shadow/supervisor.py 影子env默认
- sanguo_portfolio/runner_live.py live_env默认
- sanguo_trader/portfolio_paper.py + sanguo_api/routes_paper.py paper默认
- sanguo_api/routes_live.py create setdefault
- frontend live/paper New.vue 表单默认

测试:env mapping三态断言(缺列/0→"0",显式30不篡改)+live_env默认"0"
(RED→GREEN);CI范围642绿。存量实例DB仍存显式30,激活需配套DB迁移,必须与数据
session的get_security_info_batch SQL治本(101s→亚秒)同车部署——池放大×慢SQL=更糟。
2026-08-24 13:34:01 +08:00

504 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
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'
import { STRATEGY_LABELS } from '@/constants/strategy'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
// 策略类型:cta=个股(vnpy engine) / portfolio=组合(bullet_trade LiveEngine 子进程)
const strategyType = ref<'cta' | 'portfolio'>('cta')
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioOptions = ref<string[]>([])
const portfolioStrategy = ref('all_weather')
const poolForm = ref({ pool: 'all', max_pool: 0, benchmark: '000300.XSHG' })
const POOL_OPTIONS = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
{ label: '全市场', value: 'all' },
]
const BENCH_OPTIONS = [
{ label: '沪深300', value: '000300.XSHG' },
{ label: '中证500', value: '000905.XSHG' },
{ label: '中证1000', value: '000852.XSHG' },
{ label: '中证2000', value: '932000.XSHG' },
]
const PORTFOLIO_LABELS = STRATEGY_LABELS
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
// 级联:类名→文件名映射(实例按 code_file 归属策略)
const classToFile = ref<Record<string, string>>({})
const instancesOfCta = computed(() => {
const file = classToFile.value[form.value.strategy_class]
return file ? instanceOptions.value.filter((i) => i.code_file === file) : instanceOptions.value
})
const instancesOfPortfolio = computed(() =>
instanceOptions.value.filter((i) => i.code_file.replace(/\.py$/, '') === portfolioStrategy.value),
)
onMounted(async () => {
try {
const { data } = await apiClient.get<{ files: { name: string; type: string; class_name: string }[] }>('/strategy/files')
portfolioOptions.value = data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
classToFile.value = Object.fromEntries(data.files.map((f) => [f.class_name, f.name]))
} catch {
/* 下拉加载失败不阻塞表单 */
}
try {
instanceOptions.value = await getInstances()
} catch {
/* 档案下拉失败不阻塞 */
}
})
const form = ref<LiveCreateRequest>({
name: '', // #82 空=未选实例时预览显示占位提示;选实例后 autoName 生成(服务端兜底双保险)
account: '66639661',
vt_symbol: '600000',
strategy_class: 'AShareDoubleMaStrategy',
strategy_name: 'AShareDoubleMa_600000',
setting: {
fast_window: 5,
slow_window: 20,
window: 15,
size: 100,
forbid_short: true,
},
interval: 'd',
initial_capital: 1_000_000,
connect_wait_sec: 10,
init_wait_sec: 60,
mini_path: 'C:\\国金QMT交易端模拟\\userdata_mini',
})
// 换策略后档案不属于它 → 清空(避免绑错档案)。置 form 声明后(watch 源立即求值)
watch([() => form.value.strategy_class, portfolioStrategy], () => {
if (selectedInstanceId.value == null) return
const pool = isPortfolio.value ? instancesOfPortfolio.value : instancesOfCta.value
if (!pool.some((i) => i.id === selectedInstanceId.value)) selectedInstanceId.value = null
})
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([
apiClient.get<{ instance: { name?: string; code_file: string; symbol_or_pool: string; interval: string; params: Record<string, unknown>; type?: string } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: { name: string; class_name: string; type: string }[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
const cls = file?.class_name || inst.code_file
if (file?.type === 'portfolio' || file?.type === 'cta') strategyType.value = file.type
form.value.strategy_class = cls
form.value.vt_symbol = inst.symbol_or_pool || form.value.vt_symbol
form.value.strategy_name = inst.name || form.value.strategy_name
form.value.interval = inst.interval || form.value.interval
form.value.setting = { ...form.value.setting, ...(inst.params as object) }
// #72 实例名自动生成:{实例名}_v{YYYYMMDD}{minor},同实例同日递增
form.value.name = await autoName(inst.name || `inst${instId}`, Number(instId))
} catch {
/* 预填失败走默认 */
}
}
async function autoName(instName: string, instId: number): Promise<string> {
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const prefix = `${instName}_v${today}`
let minor = 0
try {
const { listLives } = await import('@/api/live')
const accs = await listLives()
minor = accs.filter((a: { instance_id?: number | null; name?: string }) =>
a.instance_id === instId && (a.name || '').startsWith(prefix)).length
} catch {
/* 列表拉不到 → minor=0 */
}
return `${prefix}${minor}`
}
onMounted(() => {
const instId = route.query.instance
if (!instId || Array.isArray(instId)) return
selectedInstanceId.value = Number(instId)
applyInstance(instId)
})
function onPickInstance(id: number | null): void {
selectedInstanceId.value = id
if (id != null) applyInstance(id)
}
async function onSubmit(): Promise<void> {
// §12.6 初始设计口径(2026-08-16 用户重申):实盘必须从实例档案发起
if (selectedInstanceId.value == null) {
ElMessage.warning('实盘必须选择「实例档案」发起——可先在策略库创建档案')
return
}
if (!form.value.name.trim()) {
// #76 名称自动生成:空=没选实例,提示选实例(不再让人填名字)
ElMessage.warning('请先选择实例——名称将自动生成')
return
}
if (!form.value.account.trim()) {
ElMessage.warning('请填写交易账号')
return
}
if (isPortfolio.value && !portfolioStrategy.value) {
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, '')
form.value.strategy_name = `${form.value.strategy_class}_${digits || 'inst'}`
}
loading.value = true
try {
const payload: LiveCreateRequest = { ...form.value, instance_id: selectedInstanceId.value }
if (isPortfolio.value) {
payload.strategy_type = 'portfolio'
payload.strategy_class = portfolioStrategy.value
payload.strategy_name = `portfolio_${portfolioStrategy.value}`
payload.pool = poolForm.value.pool
payload.max_pool = Number(poolForm.value.max_pool)
payload.benchmark = poolForm.value.benchmark
} else {
payload.strategy_type = 'cta'
}
const res = await createLive(payload)
ElMessage.success(`已创建实盘实例 #${res.accountId}(stopped),请到列表点"启动"`)
router.push('/live')
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page live-new">
<div class="page-head">
<div>
<h2 class="page-title">新建实盘</h2>
<p class="page-subtitle">miniQMT 直连 · 个股 CTA / 组合策略实盘(supervisor 轮询)</p>
</div>
</div>
<el-alert
type="info"
:closable="false"
title="创建后状态为 stopped,需到列表点『启动』才会启动(supervisor 轮询发现后拉起)"
/>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">基本配置</span></template>
<div class="seg-label">策略类型</div>
<div class="seg-row">
<div class="seg-card" :class="{ active: strategyType === 'cta' }" @click="strategyType = 'cta'">
<div class="seg-title">CTA 个股策略</div>
<div class="seg-desc">单标的信号型,vnpy engine 进程内跑</div>
</div>
<div class="seg-card" :class="{ active: strategyType === 'portfolio' }" @click="strategyType = 'portfolio'">
<div class="seg-title">组合策略</div>
<div class="seg-desc">选股轮动型,bullet_trade LiveEngine 子进程</div>
</div>
</div>
<el-form label-width="140px" style="margin-top:16px">
<!-- #76 实例名自动生成实例名+日期minor不再手填只读预览选实例即刷新 -->
<el-form-item label="实例名">
<span class="mono" style="font-size: 13px; color: var(--brand)">{{ form.name || '—(选实例后自动生成)' }}</span>
<span class="muted form-hint">自动生成=实例名_v日期+序号无需修改</span>
</el-form-item>
<el-form-item label="交易账号" required>
<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" @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" />
<span class="muted form-hint">miniQMT userdata_mini 路径;空时后端用 env SANGUO_QMT_PATH 或内置默认</span>
</el-form-item>
</el-form>
</el-card>
<!-- CTA 表单 -->
<template v-if="!isPortfolio">
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与策略</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="策略 / 实例">
<el-select v-model="form.strategy_class" style="width: 300px">
<el-option
v-for="opt in strategyClassOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="必选:策略实例(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfCta" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选实盘从实例发起 DoubleMa 完成 A 股适配</span>
</el-form-item>
<el-form-item label="标的(vt_symbol)">
<el-input v-model="form.vt_symbol" placeholder="600000 或 600000.SSE" style="width: 320px" />
<span class="muted form-hint">可只写 6 位代码,自动补交易所后缀(.SSE / .SZSE)</span>
</el-form-item>
<el-form-item label="K 线周期">
<el-select v-model="form.interval" style="width: 220px">
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value" :label="o.label">
<span>{{ o.label }}</span>
<span v-if="!o.replayOk" class="muted" style="float: right; font-size: 12px">影子柜台实时可用·回放暂无本地数据</span>
</el-option>
</el-select>
<span class="muted form-hint">miniQMT 成品K线,默认日线</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">策略参数(setting)</span></template>
<el-form :model="form.setting" label-width="140px">
<el-form-item label="fast_window">
<el-input-number v-model="form.setting.fast_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">快均线周期</span>
</el-form-item>
<el-form-item label="slow_window">
<el-input-number v-model="form.setting.slow_window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">慢均线周期</span>
</el-form-item>
<el-form-item label="window">
<el-input-number v-model="form.setting.window as number" :min="1" :step="1" style="width: 200px" />
<span class="muted form-hint">辅助计算窗口</span>
</el-form-item>
<el-form-item label="size">
<el-input-number v-model="form.setting.size as number" :min="1" :step="100" style="width: 200px" />
<span class="muted form-hint">定寸股数(A 100 的倍数)</span>
</el-form-item>
<el-form-item label="forbid_short">
<el-switch v-model="form.setting.forbid_short as boolean" />
<span class="muted form-hint">禁止做空(A 股默认开)</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">连接参数(可选)</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="引擎实例名">
<el-input v-model="form.strategy_name" placeholder="AShareDoubleMa_600000" style="width: 320px" />
<span class="muted form-hint">engine 内唯一的技术标识;选档案后自动填,一般不改</span>
</el-form-item>
<el-form-item label="connect_wait_sec">
<el-input-number v-model="form.connect_wait_sec as number" :min="1" :step="5" style="width: 200px" />
<span class="muted form-hint">连接 QMT 等待秒数</span>
</el-form-item>
<el-form-item label="init_wait_sec">
<el-input-number v-model="form.init_wait_sec as number" :min="1" :step="10" style="width: 200px" />
<span class="muted form-hint">策略初始化等待秒数</span>
</el-form-item>
</el-form>
</el-card>
</template>
<!-- 组合策略表单 -->
<el-card v-else class="blk" shadow="never">
<template #header><span class="section-title">组合策略与选股</span></template>
<el-form :model="poolForm" label-width="140px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 280px">
<el-option
v-for="p in portfolioOptions" :key="p"
:label="PORTFOLIO_LABELS[p] ? `${PORTFOLIO_LABELS[p]}${p}` : p"
:value="p"
/>
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="必选:策略实例(策略库可创建)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfPortfolio" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint" style="color: var(--amber)">必选实盘从实例发起supervisor 拉起 runner_live 子进程</span>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
<el-option v-for="o in POOL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="选股池上限">
<el-input-number v-model="poolForm.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
<span class="muted form-hint">0=不限, N=前N只默认30</span>
</el-form-item>
<el-form-item label="比较基准">
<el-select v-model="poolForm.benchmark" style="width: 220px">
<el-option v-for="b in BENCH_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
</el-form-item>
<el-form-item label="K 线周期">
<el-select v-model="form.interval" style="width: 220px">
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value" :label="o.label">
<span>{{ o.label }}</span>
<span v-if="!o.replayOk" class="muted" style="float: right; font-size: 12px">影子柜台实时可用·回放暂无本地数据</span>
</el-option>
</el-select>
<span class="muted form-hint">miniQMT 成品K线,默认日线</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<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>
</div>
</div>
</template>
<style scoped>
.live-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.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 {
flex: 1;
max-width: 280px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.seg-card:hover { border-color: var(--brand); background: var(--bg-hover); }
.seg-card.active { border-color: var(--brand); background: rgba(24, 144, 255, 0.10); }
.seg-title { font-size: 14px; font-weight: 600; color: var(--text); }
.seg-desc { font-size: 12px; color: var(--text-3); margin-top: 4px; }
</style>