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

385 lines
16 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, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaper, type PaperCreate } from '@/api/paper'
import { apiClient } from '@/api/client'
import { getStrategies, getInstances, type StrategyItem, type Instance } from '@/api/strategy'
import { INTERVAL_OPTIONS } from '@/constants/intervals'
import { STRATEGY_LABELS } from '@/constants/strategy'
import { disableFutureDate } from '@/utils/dates'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const fromInstance = ref('')
const strategyOptions = ref<StrategyItem[]>([])
const portfolioOptions = ref<string[]>([])
// 策略类型:cta=个股策略 / portfolio=组合策略(组合仅支持实走,历史回放走「组合回测」)
const strategyType = ref<'cta' | 'portfolio'>('cta')
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
const poolForm = reactive({ 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
onMounted(async () => {
try {
const [strats, files] = await Promise.all([
getStrategies(),
apiClient.get<{ files: { name: string; type: string }[] }>('/strategy/files'),
])
strategyOptions.value = strats
portfolioOptions.value = files.data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
} catch {
/* 下拉加载失败不阻塞表单 */
}
try {
instanceOptions.value = await getInstances()
} catch {
/* 档案下拉失败不阻塞 */
}
})
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('paper_date_range')
if (s) return JSON.parse(s) as { start: string; end: string }
} catch {
/* ignore */
}
const end = new Date()
const start = new Date(end)
start.setFullYear(start.getFullYear() - 1)
const f = (d: Date): string => d.toISOString().slice(0, 10)
return { start: f(start), end: f(end) }
}
const dr = loadDateRange()
const form = ref<PaperCreate>({
mode: 'replay',
interval: 'd',
symbols: ['600000'],
strategies: [{ name: 'DoubleMaStrategy', params: { fast_window: 5, slow_window: 10 }, match_session: 'next_open', symbol: '600000' }],
initial_capital: 1_000_000,
start: dr.start,
end: dr.end,
})
watch(
() => [form.value.start, form.value.end],
([s, e]) => {
try {
localStorage.setItem('paper_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const modes = [
{ value: 'replay', label: '回放', desc: '历史重放,立即出结果' },
{ value: 'live', label: '实走', desc: '每日 20:30 定时结算,长期跟踪' },
{ value: 'shadow', label: '影子', desc: 'VPS 影子柜台盘中实时本地撮合' },
]
const sessions = [
{ value: 'next_open', label: '次日开盘', desc: '收盘型信号,T+1 开盘撮合' },
{ value: 'current_close', label: '当日收盘', desc: '尾盘型信号,当日收盘撮合' },
]
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; match_session: string; params: Record<string, unknown> } }>(`/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
const sym = inst.symbol_or_pool || '600000'
form.value.interval = inst.interval || 'd'
form.value.symbols = sym.includes(',') ? sym.split(',').map((s) => s.trim()) : [sym]
form.value.strategies = [{ name: cls, params: inst.params || {}, match_session: inst.match_session || 'next_open', symbol: sym }]
if (file?.type === 'portfolio' || file?.type === 'cta') strategyType.value = file.type
if (route.query.mode) form.value.mode = String(route.query.mode)
} 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)
}
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioStrategy = ref('all_weather')
function setMode(m: string): void {
// 组合类型不支持回放(历史回放走「组合回测」页)
if (isPortfolio.value && m === 'replay') return
form.value.mode = m
}
// 组合类型默认实走(历史回放走「组合回测」页)
watch(strategyType, (t) => {
if (t === 'portfolio') {
if (form.value.mode === 'replay') form.value.mode = 'live'
if (!portfolioStrategy.value && portfolioOptions.value.length) {
portfolioStrategy.value = portfolioOptions.value[0]
}
}
})
async function onSubmit(): Promise<void> {
loading.value = true
try {
const payload: PaperCreate = { ...form.value, instance_id: selectedInstanceId.value }
if (strategyType.value === 'portfolio') {
payload.strategy_type = 'portfolio'
payload.symbols = [poolForm.pool]
payload.strategies = [{
name: portfolioStrategy.value,
params: { max_pool: poolForm.max_pool, benchmark: poolForm.benchmark },
match_session: 'next_open',
symbol: poolForm.pool,
}]
payload.pool = poolForm.pool
payload.max_pool = Number(poolForm.max_pool)
payload.benchmark = poolForm.benchmark
}
// 影子模式:engine=shadow(VPS 柜台盘中实时撮合);其余 eod_replay(回放/日终)
payload.engine = payload.mode === 'shadow' ? 'shadow' : 'eod_replay'
// 实走/影子是开放账户:起止日期无意义,开始=创建当天,结束留空(回放才需要历史区间)
if (payload.mode !== 'replay') {
payload.start = new Date().toISOString().slice(0, 10)
payload.end = ''
}
const aid = await createPaper(payload)
ElMessage.success(payload.mode === 'shadow'
? `已创建影子柜台模拟盘 #${aid}VPS 柜台运行期间盘中实时结算)`
: payload.mode === 'live'
? `已创建模拟盘 #${aid}(今晚 20:30 起每日结算)`
: `已创建回放模拟盘 #${aid}`)
router.push(payload.mode === 'replay' ? `/paper/result/${aid}` : `/paper/live/${aid}`)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
loading.value = false
}
}
function onSymbols(v: string): void {
form.value.symbols = v.split(',').map((s) => s.trim()).filter(Boolean)
}
</script>
<template>
<div class="page paper-new">
<div class="page-head">
<div>
<h2 class="page-title">新建模拟盘</h2>
<p class="page-subtitle">回放历史重放/ 实走每日定时· 日频 / 15 分钟</p>
</div>
</div>
<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">单标的信号型回放 / 实走</div>
</div>
<div
class="seg-card" :class="{ active: strategyType === 'portfolio' }"
@click="strategyType = 'portfolio'"
>
<div class="seg-title">组合策略</div>
<div class="seg-desc">选股轮动型仅实走回放走组合回测</div>
</div>
</div>
<div class="seg-label" style="margin-top:16px">运行模式</div>
<div class="seg-row">
<div
v-for="m in modes" :key="m.value"
class="seg-card" :class="{ active: form.mode === m.value, disabled: isPortfolio && m.value === 'replay' }"
@click="setMode(m.value)"
>
<div class="seg-title">{{ m.label }}</div>
<div class="seg-desc">{{ isPortfolio && m.value === 'replay' ? '组合策略不支持(请用组合回测)' : m.desc }}</div>
</div>
</div>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">{{ isPortfolio ? '组合策略与选股' : '标的与策略' }}</span></template>
<el-form label-width="120px" style="margin-bottom: 4px">
<el-form-item label="实例档案">
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案发起(推荐);不选=自由配置,提交后自动建档"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || i.symbol_or_pool}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}提交用档案参数发起时快照</span>
<span v-else class="muted form-hint">策略库点实走/影子会带档案跳进来</span>
</el-form-item>
</el-form>
<el-form v-if="isPortfolio" :model="poolForm" label-width="120px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 320px">
<el-option
v-for="p in portfolioOptions" :key="p"
:label="PORTFOLIO_LABELS[p] ? `${PORTFOLIO_LABELS[p]}${p}` : p"
:value="p"
/>
</el-select>
</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="K 线周期">
<el-select v-model="form.interval" style="width: 220px">
<el-option v-for="i in INTERVAL_OPTIONS" :key="i.value" :value="i.value" :label="i.label">
<span>{{ i.label }}</span>
<span v-if="!i.replayOk" class="muted" style="float:right;font-size:12px">影子柜台实时可用·回放暂无本地数据</span>
</el-option>
</el-select>
<span class="muted form-hint">{{ INTERVAL_OPTIONS.find(o => o.value === form.interval)?.matchHint || (form.interval === 'd' ? '' : '分钟级周期需标的分钟数据') }}</span>
</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>
<el-form v-else :model="form" label-width="120px">
<el-form-item label="标的(逗号分隔)">
<el-input
:model-value="form.symbols.join(',')"
placeholder="600000,000001"
style="width: 360px"
@update:model-value="onSymbols"
/>
<span class="muted form-hint">多个标的用英文逗号分隔</span>
</el-form-item>
<el-form-item label="K 线周期">
<el-select v-model="form.interval" style="width: 220px">
<el-option v-for="i in INTERVAL_OPTIONS" :key="i.value" :value="i.value" :label="i.label">
<span>{{ i.label }}</span>
<span v-if="!i.replayOk" class="muted" style="float:right;font-size:12px">影子柜台实时可用·回放暂无本地数据</span>
</el-option>
</el-select>
<span class="muted form-hint">{{ INTERVAL_OPTIONS.find(o => o.value === form.interval)?.matchHint || (form.interval === 'd' ? '' : '分钟级周期需标的分钟数据') }}</span>
</el-form-item>
<el-form-item label="策略">
<el-select
v-model="form.strategies[0].name"
filterable
allow-create
placeholder="选择或输入策略类名"
style="width: 320px"
>
<el-option v-for="s in strategyOptions" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
<span class="muted form-hint">CTA 策略个股 DoubleMaStrategy</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
<el-option v-for="s in sessions" :key="s.value" :label="`${s.label}${s.desc}`" :value="s.value" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header>
<span class="section-title">{{ form.mode === 'replay' ? '资金与区间' : '资金' }}</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="起始资金">
<el-input-number v-model="form.initial_capital" :min="10000" :step="100000" style="width: 220px" />
<span class="muted form-hint">单位</span>
</el-form-item>
<template v-if="form.mode === 'replay'">
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" style="width: 220px" />
</el-form-item>
</template>
<div v-else class="muted" style="font-size: 12px; margin: -8px 0 4px">
{{ form.mode === 'shadow' ? '影子柜台从创建当天开始实时跟踪,无结束日期' : '每日 20:30 从创建当天开始结算,无结束日期' }}
</div>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="loading" @click="onSubmit">
创建模拟盘
</el-button>
</div>
</div>
</template>
<style scoped>
.paper-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.seg-label { font-size: 12px; color: var(--text-3); margin-bottom: 8px; }
.seg-row { display: flex; gap: 12px; }
.seg-card {
flex: 1;
max-width: 240px;
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-card.disabled { opacity: 0.45; cursor: not-allowed; }
.seg-title { font-size: 14px; font-weight: 600; color: var(--text); }
.seg-desc { font-size: 12px; color: var(--text-3); margin-top: 4px; }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; }
</style>