功能更新
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user