diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/ProjectBacktestTab.svelte | |
| download | strategy-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.svelte | 384 |
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> |
