Files
stock/frontend/src/views/BacktestView.vue
2026-08-15 15:36:11 +08:00

337 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 { computed, ref } from 'vue';
import { postEventBacktest } from '@/api/client';
import type { EventBacktestResponse, EventBacktestSpec } from '@/api/types';
import ConditionChips from '@/components/ConditionChips.vue';
const EXAMPLES = [
'在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少',
'RSI 低于 30 的第二天开盘买入,持有 5 天收盘卖出',
'收盘价跌破布林带下轨的次日开盘买入,持有 10 天',
];
const text = ref(EXAMPLES[0]);
const tsCode = ref('');
const startDate = ref('');
const endDate = ref('');
const loading = ref(false);
const note = ref('');
const error = ref('');
const result = ref<EventBacktestResponse | null>(null);
// 调参重跑的本地 spec首次由后端 LLM 解析返回,后续直接传给后端跳过解析)
const spec = ref<EventBacktestSpec | null>(null);
const holdingDays = ref(3);
const entryTiming = ref<'next_open' | 'next_close'>('next_open');
const exitTiming = ref<'close' | 'open'>('close');
const stats = computed(() => result.value?.stats ?? null);
const bestTrades = computed(() => {
const t = result.value?.trades ?? [];
return t.length > 100 ? t.slice(0, 100) : t;
});
const worstTrades = computed(() => {
const t = result.value?.trades ?? [];
return t.length > 100 ? t.slice(-100) : [];
});
const fmtPct = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
const fmtDate = (s: string) => s.slice(0, 10);
async function run(specDirect?: EventBacktestSpec | null) {
if (loading.value) return;
const t = text.value.trim();
if (!specDirect && t.length < 2) {
error.value = '请先输入回测需求描述';
return;
}
loading.value = true;
error.value = '';
note.value = specDirect
? '正在按调整后的参数重新回测(全市场扫描可能需要几分钟)…'
: '正在解析回测参数并扫描全市场(可能需要几分钟)…';
try {
const res = await postEventBacktest({
text: t,
spec: specDirect ?? undefined,
ts_code: tsCode.value.trim() || null,
start: startDate.value || null,
end: endDate.value || null,
});
result.value = res;
spec.value = res.spec;
holdingDays.value = res.spec.holding_days;
entryTiming.value = res.spec.entry_timing;
exitTiming.value = res.spec.exit_timing;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
result.value = null;
} finally {
loading.value = false;
note.value = '';
}
}
async function rerunAdjusted() {
if (!spec.value) return;
await run({
...spec.value,
holding_days: holdingDays.value,
entry_timing: entryTiming.value,
exit_timing: exitTiming.value,
});
}
</script>
<template>
<div>
<!-- 输入区 -->
<div class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-[#A8AFB8]">事件回测描述一个信号 次日买入 持有 N 的事件统计历史上全市场或单只股票的收益分布</div>
</div>
<textarea
v-model="text"
rows="2"
class="mt-3 w-full resize-none rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-2 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少"
@keydown.ctrl.enter="run()"
/>
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="text-[13px] text-[#9BA3AE]">试试</span>
<button
v-for="ex in EXAMPLES"
:key="ex"
class="rounded-full border border-[#26272E] px-2.5 py-1 text-[13px] text-[#A8AFB8] hover:border-blue-500 hover:text-blue-300"
@click="text = ex"
>
{{ ex.length > 26 ? ex.slice(0, 26) + '…' : ex }}
</button>
</div>
<div class="mt-3 flex flex-wrap items-end gap-3">
<label class="text-[13px] text-[#A8AFB8]">
股票范围
<div class="mt-1 flex overflow-hidden rounded-lg border border-[#26272E] text-[13px]">
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-[#101014] text-[#A8AFB8]' : 'bg-blue-600 text-white'"
@click="tsCode = ''"
>全市场</button>
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-blue-600 text-white' : 'bg-[#101014] text-[#A8AFB8]'"
@click="tsCode ||= '000001.SZ'"
>单只股票</button>
</div>
</label>
<label v-if="tsCode" class="text-[13px] text-[#A8AFB8]">
股票代码
<input
v-model="tsCode"
class="mt-1 block w-40 rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
placeholder="000001.SZ"
/>
</label>
<label class="text-[13px] text-[#A8AFB8]">
开始日期
<input
v-model="startDate"
type="date"
class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
/>
</label>
<label class="text-[13px] text-[#A8AFB8]">
结束日期
<input
v-model="endDate"
type="date"
class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-3 py-1.5 text-sm text-[#E8EAED] outline-none placeholder:text-[#7A818C] focus:border-blue-500"
/>
</label>
<span class="text-xs text-[#9BA3AE]">留空默认最近一年</span>
<button
class="ml-auto rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="loading"
@click="run()"
>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</div>
</div>
<!-- 错误 -->
<div v-if="error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-500/40 bg-red-500/15 px-4 py-3 text-sm text-red-300">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ error }}
</div>
<!-- 加载中 -->
<div v-if="loading && note" class="py-16 text-center text-sm text-[#9BA3AE]">
<svg class="mx-auto mb-3 h-6 w-6 animate-spin text-blue-500" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ note }}
</div>
<!-- 结果 -->
<template v-else-if="result && stats">
<div class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="mb-2 text-[13px] text-[#9BA3AE]">
信号条件{{ result.universe === 'all' ? '全市场' : result.universe }}{{ fmtDate(result.start) }} ~ {{ fmtDate(result.end) }}
</div>
<ConditionChips :conditions="result.spec.entry" />
</div>
<!-- 统计卡片 -->
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">样本数</div>
<div class="mt-1 text-xl font-semibold text-[#E8EAED]">{{ stats.samples.toLocaleString() }}</div>
<div class="text-xs text-[#9BA3AE]">{{ stats.stocks.toLocaleString() }} 只股票</div>
</div>
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">平均涨幅</div>
<div class="mt-1 text-xl font-semibold" :class="stats.mean_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(stats.mean_pct) }}</div>
<div class="text-xs text-[#9BA3AE]">中位数 {{ fmtPct(stats.median_pct) }}</div>
</div>
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">胜率</div>
<div class="mt-1 text-xl font-semibold text-[#E8EAED]">{{ stats.win_rate.toFixed(2) }}%</div>
<div class="text-xs text-[#9BA3AE]">波动 σ {{ stats.std_pct.toFixed(2) }}</div>
</div>
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">P10 / P25</div>
<div class="mt-1 text-[15px] font-semibold text-down">{{ fmtPct(stats.p10_pct) }} / {{ fmtPct(stats.p25_pct) }}</div>
<div class="text-xs text-[#9BA3AE]">最差 {{ fmtPct(stats.min_pct) }}</div>
</div>
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">P75 / P90</div>
<div class="mt-1 text-[15px] font-semibold text-up">{{ fmtPct(stats.p75_pct) }} / {{ fmtPct(stats.p90_pct) }}</div>
<div class="text-xs text-[#9BA3AE]">最好 {{ fmtPct(stats.max_pct) }}</div>
</div>
<div class="rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-xs text-[#9BA3AE]">收益口径</div>
<div class="mt-1 text-sm leading-5 text-[#E8EAED]">持有 {{ result.spec.holding_days }} 个交易日<br>{{ result.spec.entry_timing === 'next_open' ? '次日开盘' : '次日收盘' }}买入 {{ result.spec.exit_timing === 'close' ? '收盘' : '开盘' }}卖出</div>
</div>
</div>
<!-- 调参重跑 -->
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
<div class="text-[13px] font-medium text-[#A8AFB8]">调整参数重跑不动信号条件</div>
<label class="text-[13px] text-[#A8AFB8]">
持有天数
<input
v-model.number="holdingDays"
type="number" min="1" max="250"
class="mt-1 block w-20 rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500"
/>
</label>
<label class="text-[13px] text-[#A8AFB8]">
买入时机
<select v-model="entryTiming" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500">
<option value="next_open">次日开盘</option>
<option value="next_close">次日收盘</option>
</select>
</label>
<label class="text-[13px] text-[#A8AFB8]">
卖出价
<select v-model="exitTiming" class="mt-1 block rounded-lg border border-[#26272E] bg-[#16181D] px-2 py-1.5 text-sm text-[#E8EAED] outline-none focus:border-blue-500">
<option value="close">收盘</option>
<option value="open">开盘</option>
</select>
</label>
<button
class="rounded-lg border border-blue-500 px-4 py-1.5 text-sm font-medium text-blue-300 hover:bg-blue-500/15 disabled:opacity-50"
:disabled="loading || !spec"
@click="rerunAdjusted()"
>按新参数重跑</button>
</div>
<!-- 分年统计 -->
<div v-if="stats.by_year.length" class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-sm font-medium text-[#A8AFB8]">分年统计</div>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#1E2026] text-left text-[13px] text-[#9BA3AE]">
<th class="py-1.5 font-normal">年份</th>
<th class="py-1.5 font-normal">样本数</th>
<th class="py-1.5 font-normal">平均涨幅</th>
<th class="py-1.5 font-normal">中位数</th>
<th class="py-1.5 font-normal">胜率</th>
</tr>
</thead>
<tbody>
<tr v-for="y in stats.by_year" :key="y.year" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ y.year }}</td>
<td class="py-1.5">{{ y.samples.toLocaleString() }}</td>
<td class="py-1.5 font-medium" :class="y.mean_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(y.mean_pct) }}</td>
<td class="py-1.5" :class="y.median_pct >= 0 ? 'text-up' : 'text-down'">{{ fmtPct(y.median_pct) }}</td>
<td class="py-1.5">{{ y.win_rate.toFixed(2) }}%</td>
</tr>
</tbody>
</table>
</div>
<!-- 样本明细 -->
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-sm font-medium text-[#A8AFB8]">表现最好的样本 {{ bestTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[13px]">
<thead class="sticky top-0 bg-[#101014]">
<tr class="border-b border-[#1E2026] text-left text-xs text-[#9BA3AE]">
<th class="py-1.5 font-normal">代码</th>
<th class="py-1.5 font-normal">买入日</th>
<th class="py-1.5 font-normal">买入价</th>
<th class="py-1.5 font-normal">卖出价</th>
<th class="py-1.5 font-normal">收益</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in bestTrades" :key="i" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ t.ts_code }} <span class="text-[#9BA3AE]">{{ t.name }}</span></td>
<td class="py-1.5 text-[#A8AFB8]">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_price }}</td>
<td class="py-1.5">{{ t.exit_price }}</td>
<td class="py-1.5 font-medium text-up">{{ fmtPct(t.ret_pct) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="worstTrades.length" class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
<div class="mb-2 text-sm font-medium text-[#A8AFB8]">表现最差的样本 {{ worstTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[13px]">
<thead class="sticky top-0 bg-[#101014]">
<tr class="border-b border-[#1E2026] text-left text-xs text-[#9BA3AE]">
<th class="py-1.5 font-normal">代码</th>
<th class="py-1.5 font-normal">买入日</th>
<th class="py-1.5 font-normal">买入价</th>
<th class="py-1.5 font-normal">卖出价</th>
<th class="py-1.5 font-normal">收益</th>
</tr>
</thead>
<tbody>
<tr v-for="(t, i) in worstTrades" :key="i" class="border-b border-[#1E2026]">
<td class="py-1.5">{{ t.ts_code }} <span class="text-[#9BA3AE]">{{ t.name }}</span></td>
<td class="py-1.5 text-[#A8AFB8]">{{ fmtDate(t.entry_date) }}</td>
<td class="py-1.5">{{ t.entry_price }}</td>
<td class="py-1.5">{{ t.exit_price }}</td>
<td class="py-1.5 font-medium text-down">{{ fmtPct(t.ret_pct) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-2 text-xs text-[#9BA3AE]">
{{ result.total.toLocaleString() }} 个样本收益率已按复权因子校正消除除权除息失真明细仅展示最好/最差各 100
</div>
</template>
<div v-else-if="!error && !loading" class="py-16 text-center text-sm text-[#9BA3AE]">
用一句话描述你的想法例如连续三天 J 小于 10 时次日开盘买入未来三天涨多少
</div>
</div>
</template>