summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectBacktestTab.svelte
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/ProjectBacktestTab.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/ProjectBacktestTab.svelte')
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte384
1 files changed, 384 insertions, 0 deletions
diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte
new file mode 100644
index 0000000..baa9e5f
--- /dev/null
+++ b/frontend/src/pages/ProjectBacktestTab.svelte
@@ -0,0 +1,384 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import { listDatasets, createRun, getRun, cancelRun, rerunRun, listRuns, getDatasetPreview } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import {
+ fmtDateTime,
+ fmtPct,
+ formatMoney,
+ datasetStatusLabel,
+ runStatusLabel,
+ runStatusBadge
+ } from '../lib/format';
+ import { summarizeCoverage } from '../lib/coverage';
+ import type { Run, Dataset } from '../lib/types';
+
+ interface Props {
+ projectId: string;
+ }
+ let { projectId }: Props = $props();
+
+ // config inputs
+ let datasetsReady: Dataset[] = $state([]);
+ let busyDatasets = $state(true);
+ let cfgErr = $state<string | null>(null);
+
+ let datasetId = $state('');
+ let capital = $state<number>(1_000_000);
+ let commission = $state(0.0003);
+ let slippage = $state(0.001);
+ let benchmark = $state('');
+ let paramsText = $state('');
+ let acknowledged = $state(false);
+
+ // life cycle
+ let latest = $state<Run | null>(null);
+ let latestErr = $state<string | null>(null);
+ let cancelBusy = $state(false);
+ let rerunBusy = $state(false);
+ let actionMsg = $state<string | null>(null);
+
+ let history: Run[] = $state([]);
+ let historyBusy = $state(false);
+ let historyErr = $state<string | null>(null);
+
+ $effect(() => {
+ void loadDatasets();
+ void loadLatest(true);
+ return () => {
+ if (pollTimer) clearInterval(pollTimer);
+ };
+ });
+
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
+
+ function isLiveStatus(r: Run | null | undefined) {
+ return r?.status === 'queued' || r?.status === 'running';
+ }
+
+ async function loadDatasets() {
+ busyDatasets = true;
+ cfgErr = null;
+ try {
+ const { items } = await listDatasets();
+ datasetsReady = items.filter((d) => d.status === 'ready');
+ if (items.some((d) => d.status === 'pending' || d.status === 'running')) {
+ if (!waitTimer) {
+ waitTimer = setInterval(() => {
+ void loadDatasets();
+ }, 2500);
+ }
+ } else {
+ stopWait();
+ }
+ } catch (e) {
+ cfgErr = extractStatusMessage(e);
+ } finally {
+ busyDatasets = false;
+ }
+ }
+
+ let waitTimer: ReturnType<typeof setInterval> | null = null;
+
+ function stopWait() {
+ if (waitTimer) {
+ clearInterval(waitTimer);
+ waitTimer = null;
+ }
+ }
+
+ async function loadLatest(startPolling: boolean) {
+ latestErr = null;
+ try {
+ const res = await listRuns(projectId);
+ history = res.items;
+ latest = res.items[0] ?? null;
+ if (startPolling && isLiveStatus(latest) && !pollTimer) {
+ pollTimer = setInterval(() => void loadLatest(false), 2000);
+ }
+ if (!isLiveStatus(latest) && pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ await refreshHistory();
+ }
+ } catch (e) {
+ latestErr = extractStatusMessage(e);
+ }
+ }
+
+ async function refreshHistory() {
+ historyBusy = true;
+ historyErr = null;
+ try {
+ const res = await listRuns(projectId);
+ history = res.items;
+ } catch (e) {
+ historyErr = extractStatusMessage(e);
+ } finally {
+ historyBusy = false;
+ }
+ }
+
+ async function previewWarningsForDataset(id: string) {
+ try {
+ const preview = await getDatasetPreview(id);
+ const cov = summarizeCoverage(preview.coverage ?? []);
+ datasetWarnMsgs = [...cov.warnings, ...(preview.warnings ?? [])];
+ } catch {
+ datasetWarnMsgs = [];
+ }
+ acknowledged = false;
+ }
+
+ let datasetWarnMsgs: string[] = $state([]);
+
+ const selectedDataset = $derived(datasetsReady.find((d) => d.id === datasetId) ?? null);
+ const needAck = $derived(datasetWarnMsgs.length > 0);
+
+ $effect(() => {
+ if (datasetId) void previewWarningsForDataset(datasetId);
+ });
+
+ async function startRun() {
+ const errs: string[] = [];
+ if (!datasetId) errs.push('请选择一个就绪的数据集');
+ if (!(capital > 0) || !Number.isFinite(capital)) errs.push('起始资金必须是正数');
+ if (!(commission >= 0 && commission < 0.1)) errs.push('佣金率应在 0 与 0.1 之间');
+ if (!(slippage >= 0 && slippage < 0.1)) errs.push('滑点应在 0 与 0.1 之间');
+ let parameters: Record<string, unknown> | undefined;
+ if (paramsText.trim()) {
+ try {
+ parameters = JSON.parse(paramsText);
+ } catch {
+ errs.push('参数必须是合法 JSON');
+ }
+ }
+ if (errs.length > 0) {
+ cfgErr = errs.join(';');
+ return;
+ }
+ cfgErr = null;
+ if (needAck && !acknowledged) {
+ cfgErr = '数据覆盖有差异,请先勾选确认已知晓后再启动回测';
+ return;
+ }
+ try {
+ const run = await createRun({
+ project_id: projectId,
+ dataset_id: datasetId,
+ capital,
+ commission,
+ slippage,
+ benchmark_symbol: benchmark.trim() || undefined,
+ parameters,
+ acknowledge_warnings: acknowledged
+ });
+ latest = run;
+ await loadLatest(true);
+ actionMsg = `回测已提交,编号 ${run.id.slice(0, 8)}…`;
+ void refreshHistory();
+ } catch (e) {
+ cfgErr = extractStatusMessage(e);
+ const details = (e as { details?: { errors?: string[] } }).details?.errors;
+ if (details) cfgErr = details.join(';');
+ }
+ }
+
+
+
+ async function onCancel() {
+ if (!latest) return;
+ cancelBusy = true;
+ try {
+ latest = await cancelRun(latest.id);
+ actionMsg = '已请求取消';
+ } catch (e) {
+ actionMsg = extractStatusMessage(e);
+ } finally {
+ cancelBusy = false;
+ }
+ }
+
+ async function onRerun() {
+ if (!latest) return;
+ rerunBusy = true;
+ try {
+ const run = await rerunRun(latest.id, true);
+ latest = run;
+ await loadLatest(true);
+ actionMsg = `已用原始代码与数据重新提交,编号 ${run.id.slice(0, 8)}…`;
+ } catch (e) {
+ actionMsg = extractStatusMessage(e);
+ } finally {
+ rerunBusy = false;
+ }
+ }
+
+ const canCancel = $derived(isLiveStatus(latest));
+ const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled');
+</script>
+
+<div class="stack">
+ <section class="card stack">
+ <h2>发起回测</h2>
+ <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} />
+ {#if !busyDatasets && datasetsReady.length === 0 && !cfgErr}
+ <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" />
+ {/if}
+ {#if datasetsReady.length > 0}
+ <div class="grid3 wrap-3">
+ <label>
+ 数据集
+ <select bind:value={datasetId}>
+ <option value="" disabled>选择数据集…</option>
+ {#each datasetsReady as d (d.id)}
+ <option value={d.id}>
+ {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')}
+ </option>
+ {/each}
+ </select>
+ </label>
+ <label>
+ 起始资金
+ <input class="num" type="number" bind:value={capital} min="1" step="1000" />
+ </label>
+ <div class="row2">
+ <label>
+ 佣金率
+ <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" />
+ </label>
+ <label>
+ 滑点率
+ <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" />
+ </label>
+ </div>
+ <label>
+ 基准代码(可选)
+ <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" />
+ </label>
+ <label>
+ 策略参数(JSON,可选)
+ <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea>
+ </label>
+ </div>
+ {#if needAck}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <div>
+ 该数据集存在覆盖/预警信息,需要明确知晓后才能启动:
+ <ul>
+ {#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each}
+ </ul>
+ <label class="checkline">
+ <input type="checkbox" bind:checked={acknowledged} />
+ 我已知晓并用当前数据继续
+ </label>
+ </div>
+ </div>
+ {/if}
+ <div class="row">
+ <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button>
+ {#if selectedDataset}
+ <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
+ {/if}
+ </div>
+ <Status busy={false} error={cfgErr} empty={actionMsg} />
+ {/if}
+ </section>
+
+ <section class="card stack">
+ <div class="spread">
+ <h2>最近一次回测</h2>
+ {#if latest}
+ <span class="row">
+ <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span>
+ <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span>
+ </span>
+ {/if}
+ </div>
+
+ {#if latest}
+ <Status
+ busy={isLiveStatus(latest)}
+ busyText="排队/执行中,页面会自动刷新状态…"
+ empty={null}
+ error={latestErr}
+ />
+ <div class="rows-grid">
+ <table class="data kv">
+ <tbody>
+ <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr>
+ {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if}
+ {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if}
+ <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr>
+ <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr>
+ <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr>
+ </tbody>
+ </table>
+ </div>
+ {#if latest.error}
+ <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div>
+ {/if}
+ {#if latest.result?.warnings?.length}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <ul class="plain-list">
+ {#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each}
+ </ul>
+ </div>
+ {/if}
+ {#if latest.status === 'succeeded' && latest.result}
+ <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a>
+ {/if}
+ <div class="row">
+ {#if canCancel}
+ <button class="btn danger" onclick={onCancel} disabled={cancelBusy}>
+ {cancelBusy ? '取消中…' : '取消回测'}
+ </button>
+ {/if}
+ {#if canRerun}
+ <button class="btn" onclick={onRerun} disabled={rerunBusy}>
+ {rerunBusy ? '重新提交…' : '按原始代码&数据重跑'}
+ </button>
+ {/if}
+ </div>
+ {:else}
+ <Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" />
+ {/if}
+ </section>
+
+ <section class="stack">
+ <h2>回测历史</h2>
+ <Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} />
+ {#if history && history.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>启动时间</th>
+ <th>状态</th>
+ <th>资金/佣金/滑点</th>
+ <th>数据集</th>
+ <th>结果</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each history as h (h.id)}
+ <tr>
+ <td>{fmtDateTime(h.created_at)}</td>
+ <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td>
+ <td>
+ {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)}
+ </td>
+ <td class="num">{h.dataset_id?.slice(0, 8)}…</td>
+ <td>
+ <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ </section>
+</div>