Files
sanguo_vnpy_v2/frontend/src/views/live/New.vue
T

396 lines
17 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, type LiveCreateRequest } 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: 'hs300_subset', max_pool: 30, benchmark: '000300.XSHG' })
const POOL_OPTIONS = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
{ label: '全市场(慢,非 MVP)', 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: 'live-600000',
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 股策略)' },
]
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) }
} catch {
/* 预填失败走默认 */
}
}
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()) {
ElMessage.warning('请填写实例名')
return
}
if (!form.value.account.trim()) {
ElMessage.warning('请填写交易账号')
return
}
if (isPortfolio.value && !portfolioStrategy.value) {
ElMessage.warning('请选择组合策略')
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">
<el-form-item label="实例名" required>
<el-input v-model="form.name" placeholder="live-600000" style="width: 320px" />
<span class="muted form-hint">页面显示用</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" />
<span class="muted form-hint">单位:</span>
</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" @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; }
.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>