317 lines
11 KiB
Vue
317 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, watch, onMounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { getStrategies, getParams, getInstances, type StrategyItem } from '@/api/strategy'
|
||
import { submitCta, getTaskParams } from '@/api/backtest'
|
||
import { disableFutureDate } from '@/utils/dates'
|
||
import { apiClient } from '@/api/client'
|
||
|
||
interface InstLike {
|
||
code_file: string
|
||
symbol_or_pool: string
|
||
interval: string
|
||
params: Record<string, unknown>
|
||
}
|
||
interface FileLike {
|
||
name: string
|
||
class_name: string
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const strategies = ref<StrategyItem[]>([])
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const paramsList = ref<string[]>([])
|
||
const fromInstance = ref('')
|
||
let inited = false
|
||
|
||
const BENCHMARK_OPTIONS = [
|
||
{ label: '沪深 300', value: 'hs300' },
|
||
{ label: '中证 500', value: 'zz500' },
|
||
{ label: '中证 1000', value: 'zz1000' },
|
||
{ label: '中证 2000', value: 'zz2000' },
|
||
]
|
||
function loadDateRange(): { start: string; end: string } {
|
||
try {
|
||
const s = localStorage.getItem('bt_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 = reactive({
|
||
strategy: '',
|
||
symbol: '600000',
|
||
start: dr.start,
|
||
end: dr.end,
|
||
benchmark: 'hs300',
|
||
interval: 'd',
|
||
commission_rate: 0.0003,
|
||
stamp_duty_rate: 0.001,
|
||
slippage: 0.001,
|
||
})
|
||
watch(
|
||
() => [form.start, form.end],
|
||
([s, e]) => {
|
||
try {
|
||
localStorage.setItem('bt_date_range', JSON.stringify({ start: s, end: e }))
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
},
|
||
)
|
||
const paramValues = reactive<Record<string, string>>({})
|
||
|
||
async function loadParams(cls: string, override: Record<string, unknown> | null): Promise<void> {
|
||
if (!cls) return
|
||
try {
|
||
const p = await getParams(cls)
|
||
paramsList.value = p.parameters
|
||
Object.keys(paramValues).forEach((k) => delete paramValues[k])
|
||
p.parameters.forEach((k) => {
|
||
const ov = override && override[k] != null ? override[k] : p.defaults[k]
|
||
paramValues[k] = ov != null ? String(ov) : ''
|
||
})
|
||
} catch {
|
||
paramsList.value = []
|
||
}
|
||
}
|
||
|
||
// §12.6 选档案:跳转带入或页面下拉选择,提交带 instance_id 回写四格
|
||
const instanceOptions = ref<{ id: number; name: string; code_file: string; interval: string }[]>([])
|
||
const selectedInstanceId = ref<number | null>(null)
|
||
|
||
async function applyInstance(instId: string | number): Promise<void> {
|
||
try {
|
||
const [{ data: ir }, { data: fr }] = await Promise.all([
|
||
apiClient.get<{ instance: InstLike & { name?: string } }>(`/strategy/instances/${instId}`),
|
||
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
|
||
])
|
||
const inst = ir.instance
|
||
fromInstance.value = inst.name || String(instId)
|
||
const file = fr.files.find((f) => f.name === inst.code_file)
|
||
form.strategy = file?.class_name || inst.code_file
|
||
form.symbol = inst.symbol_or_pool || form.symbol
|
||
form.interval = inst.interval || form.interval
|
||
await loadParams(form.strategy, inst.params)
|
||
} catch {
|
||
/* 预填失败,走默认 */
|
||
}
|
||
}
|
||
|
||
function onPickInstance(id: number | null): void {
|
||
selectedInstanceId.value = id
|
||
if (id != null) void applyInstance(id)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
strategies.value = await getStrategies()
|
||
try {
|
||
instanceOptions.value = await getInstances()
|
||
} catch {
|
||
/* 档案下拉失败不阻塞 */
|
||
}
|
||
const instId = route.query.instance
|
||
if (instId && !Array.isArray(instId)) {
|
||
selectedInstanceId.value = Number(instId)
|
||
await applyInstance(instId)
|
||
}
|
||
// 从代码编辑页「运行回测」跳转:按策略类名预填
|
||
if (!form.strategy && route.query.class) {
|
||
form.strategy = String(route.query.class)
|
||
}
|
||
// 从任务列表「点任务ID」跳转:按该任务参数完整预填
|
||
if (route.query.from_task) {
|
||
try {
|
||
const tp = await getTaskParams(String(route.query.from_task))
|
||
form.strategy = tp.strategy || form.strategy
|
||
form.symbol = tp.symbol || form.symbol
|
||
if (tp.start) form.start = tp.start
|
||
if (tp.end) form.end = tp.end
|
||
await loadParams(form.strategy, tp.params)
|
||
} catch {
|
||
/* 预填失败,走默认 */
|
||
}
|
||
}
|
||
if (!form.strategy && strategies.value.length) {
|
||
form.strategy = strategies.value[0].name
|
||
}
|
||
inited = true
|
||
if (!route.query.instance && !route.query.from_task) await loadParams(form.strategy, null)
|
||
} catch {
|
||
ElMessage.error('策略列表加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => form.strategy,
|
||
async (name) => {
|
||
if (!inited || !name) return
|
||
await loadParams(name, null)
|
||
},
|
||
)
|
||
|
||
async function onSubmit(): Promise<void> {
|
||
if (!form.strategy || !form.symbol) {
|
||
ElMessage.warning('请选择策略并填写标的')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
const params: Record<string, unknown> = {}
|
||
paramsList.value.forEach((k) => {
|
||
const raw = paramValues[k]
|
||
params[k] = raw !== '' && !isNaN(Number(raw)) ? Number(raw) : raw
|
||
})
|
||
const tid = await submitCta({
|
||
symbol: form.symbol,
|
||
strategy: form.strategy,
|
||
params,
|
||
start: form.start,
|
||
end: form.end,
|
||
benchmark: form.benchmark,
|
||
interval: form.interval,
|
||
commission_rate: Number(form.commission_rate),
|
||
stamp_duty_rate: Number(form.stamp_duty_rate),
|
||
slippage: Number(form.slippage),
|
||
instance_id: selectedInstanceId.value,
|
||
})
|
||
ElMessage.success('回测已提交')
|
||
void tid
|
||
router.push('/backtest/history')
|
||
} catch {
|
||
ElMessage.error('提交失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page bt-new">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2 class="page-title">个股回测</h2>
|
||
<p class="page-subtitle">CTA 策略历史回测 · 提交后可在「任务列表」跟踪进度与结果</p>
|
||
</div>
|
||
</div>
|
||
|
||
<el-card class="blk" shadow="never" v-loading="loading">
|
||
<template #header><span class="section-title">策略与参数</span></template>
|
||
<el-form :model="form" label-width="120px">
|
||
<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.interval})`"
|
||
/>
|
||
</el-select>
|
||
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }},完成后回写档案</span>
|
||
</el-form-item>
|
||
<el-form-item label="策略">
|
||
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 320px">
|
||
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="paramsList.length" label="参数">
|
||
<div class="param-grid">
|
||
<div v-for="k in paramsList" :key="k" class="param-item">
|
||
<span class="param-key mono">{{ k }}</span>
|
||
<el-input v-model="paramValues[k]" style="width: 140px" />
|
||
</div>
|
||
</div>
|
||
</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="120px">
|
||
<el-form-item label="标的代码">
|
||
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 240px" />
|
||
<span class="muted form-hint">A 股代码,无需交易所后缀</span>
|
||
</el-form-item>
|
||
<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>
|
||
<el-form-item label="K 线周期">
|
||
<el-radio-group v-model="form.interval">
|
||
<el-radio-button value="d">日线</el-radio-button>
|
||
<el-radio-button value="5m">5 分钟</el-radio-button>
|
||
<el-radio-button value="15m">15 分钟</el-radio-button>
|
||
</el-radio-group>
|
||
<span class="muted form-hint">日内周期仅支持有分钟数据的标的</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="120px">
|
||
<el-form-item label="比较基准">
|
||
<el-select v-model="form.benchmark" style="width: 220px">
|
||
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||
</el-select>
|
||
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</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="120px">
|
||
<el-form-item label="佣金率">
|
||
<el-input v-model="form.commission_rate" style="width: 160px" />
|
||
<span class="muted form-hint">0.0003 = 万3(双边,最低 5 元)</span>
|
||
</el-form-item>
|
||
<el-form-item label="印花税率">
|
||
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
|
||
<span class="muted form-hint">0.001 = 千1(仅卖出)</span>
|
||
</el-form-item>
|
||
<el-form-item label="滑点(比率)">
|
||
<el-input v-model="form.slippage" style="width: 160px" />
|
||
<span class="muted form-hint">0.001 = 万10</span>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<div class="submit-bar">
|
||
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
|
||
提交回测
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.bt-new { display: flex; flex-direction: column; gap: 16px; }
|
||
.blk { border: 1px solid var(--border-2); }
|
||
|
||
.param-grid { display: flex; flex-wrap: wrap; gap: 12px 20px; }
|
||
.param-item { display: flex; align-items: center; gap: 8px; }
|
||
.param-key { font-size: 12px; color: var(--text-2); min-width: 110px; }
|
||
|
||
.form-hint { margin-left: 10px; }
|
||
.submit-bar { padding: 4px 0; }
|
||
</style>
|