feat(paper): 组合策略接入模拟盘实走(E1+E2): paper_accounts加strategy_type列(含迁移); portfolio_paper每晚20:30全量重放→当日成交/末日持仓/净值落paper表(幂等,回测引擎为单一真相源); run_live_step按类型分流; create支持portfolio(仅live); 前端新建模拟盘策略类型选择+组合字段(策略/池/上限/基准) [vps]
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
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'
|
||||
@@ -11,10 +11,38 @@ 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')
|
||||
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: Record<string, string> = {
|
||||
all_weather: '全天候轮动',
|
||||
momentum_timing: '牛熊动量',
|
||||
value_selection: '价值精选',
|
||||
small_cap: '小市值轮动',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
strategyOptions.value = await getStrategies()
|
||||
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 {
|
||||
/* 下拉加载失败不阻塞表单 */
|
||||
}
|
||||
@@ -88,12 +116,40 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const isPortfolio = computed(() => strategyType.value === 'portfolio')
|
||||
const portfolioStrategy = ref('all_weather')
|
||||
|
||||
// 组合类型锁定实走(历史回放走「组合回测」页)
|
||||
watch(strategyType, (t) => {
|
||||
if (t === 'portfolio') {
|
||||
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 aid = await createPaper(form.value)
|
||||
ElMessage.success(`已创建模拟盘 #${aid}`)
|
||||
router.push(form.value.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
|
||||
const payload: PaperCreate = { ...form.value }
|
||||
if (strategyType.value === 'portfolio') {
|
||||
payload.strategy_type = 'portfolio'
|
||||
payload.mode = 'live'
|
||||
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
|
||||
}
|
||||
const aid = await createPaper(payload)
|
||||
ElMessage.success(`已创建模拟盘 #${aid}(今晚 20:30 起每日结算)`)
|
||||
router.push(payload.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
@@ -117,27 +173,70 @@ function onSymbols(v: string): void {
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">基本配置</span></template>
|
||||
<div class="seg-label">运行模式</div>
|
||||
<div class="seg-label">策略类型</div>
|
||||
<div class="seg-row">
|
||||
<div
|
||||
v-for="m in modes" :key="m.value"
|
||||
class="seg-card" :class="{ active: form.mode === m.value }"
|
||||
@click="form.mode = m.value"
|
||||
class="seg-card" :class="{ active: strategyType === 'cta' }"
|
||||
@click="strategyType = 'cta'"
|
||||
>
|
||||
<div class="seg-title">{{ m.label }}</div>
|
||||
<div class="seg-desc">{{ m.desc }}</div>
|
||||
<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>
|
||||
<el-radio-group v-model="form.interval">
|
||||
<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="if (!(isPortfolio && m.value === 'replay')) form.mode = m.value"
|
||||
>
|
||||
<div class="seg-title">{{ m.label }}</div>
|
||||
<div class="seg-desc">{{ isPortfolio && m.value === 'replay' ? '组合策略不支持(请用组合回测)' : m.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPortfolio" class="seg-label" style="margin-top:16px">频率</div>
|
||||
<el-radio-group v-if="!isPortfolio" v-model="form.interval">
|
||||
<el-radio-button v-for="i in intervals" :key="i.value" :value="i.value">{{ i.label }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">标的与策略</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<template #header><span class="section-title">{{ isPortfolio ? '组合策略与选股' : '标的与策略' }}</span></template>
|
||||
<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="选股池上限">
|
||||
<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(',')"
|
||||
@@ -208,6 +307,7 @@ function onSymbols(v: string): void {
|
||||
}
|
||||
.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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user