337 lines
16 KiB
Vue
337 lines
16 KiB
Vue
<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>
|