功能更新

This commit is contained in:
2026-08-15 08:57:15 +08:00
parent 50fd032b45
commit c1c43d2ff7
30 changed files with 1908 additions and 888 deletions

View File

@@ -1,51 +1,336 @@
<script setup lang="ts">
import BacktestForm from '@/components/BacktestForm.vue';
import KLineChart from '@/components/KLineChart.vue';
import EquityChart from '@/components/EquityChart.vue';
import MetricsPanel from '@/components/MetricsPanel.vue';
import { useBacktestStore } from '@/stores/backtest';
import type { BacktestRequest } from '@/api/types';
import { computed, ref } from 'vue';
import { postEventBacktest } from '@/api/client';
import type { EventBacktestResponse, EventBacktestSpec } from '@/api/types';
import ConditionChips from '@/components/ConditionChips.vue';
const store = useBacktestStore();
function onRun(req: BacktestRequest) {
store.run(req);
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>
<BacktestForm :loading="store.loading" @run="onRun" />
<div v-if="store.error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<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>
{{ store.error }}
</div>
<div v-if="store.loading && store.note" class="py-16 text-center text-sm text-slate-400">
<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>
{{ store.note }}
</div>
<template v-if="store.result">
<MetricsPanel :metrics="store.result.metrics" />
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<KLineChart
:candles="store.result.candles"
:indicators="store.result.indicators"
:signals="store.result.signals"
:symbol="store.result.symbol"
:timeframe="store.result.timeframe"
:strategy="store.result.strategy"
<div>
<!-- 输入区 -->
<div class="rounded-xl border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<div class="text-[13px] font-medium text-slate-600">事件回测描述一个信号 次日买入 持有 N 的事件统计历史上全市场或单只股票的收益分布</div>
</div>
<textarea
v-model="text"
rows="2"
class="mt-3 w-full resize-none rounded-lg border border-slate-200 px-3 py-2 text-[13px] text-slate-800 outline-none focus:border-blue-400"
placeholder="例:在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少"
@keydown.ctrl.enter="run()"
/>
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="text-[12px] text-slate-400">试试</span>
<button
v-for="ex in EXAMPLES"
:key="ex"
class="rounded-full border border-slate-200 px-2.5 py-1 text-[12px] text-slate-600 hover:border-blue-300 hover:text-blue-600"
@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-[12px] text-slate-500">
股票范围
<div class="mt-1 flex overflow-hidden rounded-lg border border-slate-200 text-[12px]">
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-white text-slate-600' : 'bg-blue-600 text-white'"
@click="tsCode = ''"
>全市场</button>
<button
class="px-3 py-1.5"
:class="tsCode ? 'bg-blue-600 text-white' : 'bg-white text-slate-600'"
@click="tsCode ||= '000001.SZ'"
>单只股票</button>
</div>
</label>
<label v-if="tsCode" class="text-[12px] text-slate-500">
股票代码
<input
v-model="tsCode"
class="mt-1 block w-40 rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400"
placeholder="000001.SZ"
/>
</label>
<label class="text-[12px] text-slate-500">
开始日期
<input
v-model="startDate"
type="date"
class="mt-1 block rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400"
/>
</label>
<label class="text-[12px] text-slate-500">
结束日期
<input
v-model="endDate"
type="date"
class="mt-1 block rounded-lg border border-slate-200 px-3 py-1.5 text-[13px] outline-none focus:border-blue-400"
/>
</label>
<span class="text-[11px] text-slate-400">留空默认最近一年</span>
<button
class="ml-auto rounded-lg bg-blue-600 px-5 py-2 text-[13px] font-medium text-white hover:bg-blue-700 disabled:opacity-50"
:disabled="loading"
@click="run()"
>
{{ loading ? '回测中…' : '开始回测' }}
</button>
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-3">
<div class="px-2 py-1 text-[13px] text-slate-500">净值曲线</div>
<EquityChart :equity="store.result.equity" />
<!-- 错误 -->
<div v-if="error" class="mt-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
<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>
</template>
<div v-else-if="!store.loading" class="py-16 text-center text-sm text-slate-400">
选好周期与参数开始回测先用 DEMO 合成数据跑通
<!-- 加载中 -->
<div v-if="loading && note" class="py-16 text-center text-sm text-slate-400">
<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-slate-200 bg-white px-4 py-3">
<div class="mb-2 text-[12px] text-slate-400">
信号条件{{ 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-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">样本数</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.samples.toLocaleString() }}</div>
<div class="text-[11px] text-slate-400">{{ stats.stocks.toLocaleString() }} 只股票</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">平均涨幅</div>
<div class="mt-1 text-xl font-semibold" :class="stats.mean_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ fmtPct(stats.mean_pct) }}</div>
<div class="text-[11px] text-slate-400">中位数 {{ fmtPct(stats.median_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">胜率</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{{ stats.win_rate.toFixed(2) }}%</div>
<div class="text-[11px] text-slate-400">波动 σ {{ stats.std_pct.toFixed(2) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">P10 / P25</div>
<div class="mt-1 text-[15px] font-semibold text-green-700">{{ fmtPct(stats.p10_pct) }} / {{ fmtPct(stats.p25_pct) }}</div>
<div class="text-[11px] text-slate-400">最差 {{ fmtPct(stats.min_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">P75 / P90</div>
<div class="mt-1 text-[15px] font-semibold text-red-700">{{ fmtPct(stats.p75_pct) }} / {{ fmtPct(stats.p90_pct) }}</div>
<div class="text-[11px] text-slate-400">最好 {{ fmtPct(stats.max_pct) }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white px-4 py-3">
<div class="text-[11px] text-slate-400">收益口径</div>
<div class="mt-1 text-[13px] leading-5 text-slate-700">持有 {{ 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-slate-200 bg-white px-4 py-3">
<div class="text-[12px] font-medium text-slate-600">调整参数重跑不动信号条件</div>
<label class="text-[12px] text-slate-500">
持有天数
<input
v-model.number="holdingDays"
type="number" min="1" max="250"
class="mt-1 block w-20 rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400"
/>
</label>
<label class="text-[12px] text-slate-500">
买入时机
<select v-model="entryTiming" class="mt-1 block rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400">
<option value="next_open">次日开盘</option>
<option value="next_close">次日收盘</option>
</select>
</label>
<label class="text-[12px] text-slate-500">
卖出价
<select v-model="exitTiming" class="mt-1 block rounded-lg border border-slate-200 px-2 py-1.5 text-[13px] outline-none focus:border-blue-400">
<option value="close">收盘</option>
<option value="open">开盘</option>
</select>
</label>
<button
class="rounded-lg border border-blue-300 px-4 py-1.5 text-[13px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50"
:disabled="loading || !spec"
@click="rerunAdjusted()"
>按新参数重跑</button>
</div>
<!-- 分年统计 -->
<div v-if="stats.by_year.length" class="mt-4 rounded-xl border border-slate-200 bg-white p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">分年统计</div>
<table class="w-full text-[13px]">
<thead>
<tr class="border-b border-slate-100 text-left text-[12px] text-slate-400">
<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-slate-50">
<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-red-600' : 'text-green-600'">{{ fmtPct(y.mean_pct) }}</td>
<td class="py-1.5" :class="y.median_pct >= 0 ? 'text-red-600' : 'text-green-600'">{{ 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-slate-200 bg-white p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">表现最好的样本 {{ bestTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]">
<thead class="sticky top-0 bg-white">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400">
<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-slate-50">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ 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-red-600">{{ fmtPct(t.ret_pct) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="worstTrades.length" class="rounded-xl border border-slate-200 bg-white p-4">
<div class="mb-2 text-[13px] font-medium text-slate-600">表现最差的样本 {{ worstTrades.length }}</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full text-[12px]">
<thead class="sticky top-0 bg-white">
<tr class="border-b border-slate-100 text-left text-[11px] text-slate-400">
<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-slate-50">
<td class="py-1.5">{{ t.ts_code }} <span class="text-slate-400">{{ t.name }}</span></td>
<td class="py-1.5 text-slate-500">{{ 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-green-600">{{ fmtPct(t.ret_pct) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-2 text-[11px] text-slate-400">
{{ result.total.toLocaleString() }} 个样本收益率已按复权因子校正消除除权除息失真明细仅展示最好/最差各 100
</div>
</template>
<div v-else-if="!error && !loading" class="py-16 text-center text-sm text-slate-400">
用一句话描述你的想法例如连续三天 J 小于 10 时次日开盘买入未来三天涨多少
</div>
</div>
</template>