385 lines
16 KiB
Vue
385 lines
16 KiB
Vue
<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>
|