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

317 lines
11 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, 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>