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/RunsPage.svelte | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/RunsPage.svelte')
| -rw-r--r-- | frontend/src/pages/RunsPage.svelte | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte new file mode 100644 index 0000000..bd77892 --- /dev/null +++ b/frontend/src/pages/RunsPage.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption } from 'echarts'; + import { listRuns } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format'; + import type { Run } from '../lib/types'; + + let runs: Run[] = $state([]); + let busy = $state(true); + let error = $state<string | null>(null); + let chosenIds: string[] = $state([]); + let failedLoad = $state<string[]>([]); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + try { + const { items } = await listRuns(); + runs = items; + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const selected = $derived(runs.filter((r) => chosenIds.includes(r.id))); + const comparable = $derived( + selected.filter((r) => r.status === 'succeeded' && r.result) + ); + + function toggle(id: string) { + if (chosenIds.includes(id)) chosenIds = chosenIds.filter((x) => x !== id); + else chosenIds = [...chosenIds.slice(-3), id]; + } + + const compareOption: EChartsOption = $derived.by(() => { + if (comparable.length === 0) return {}; + const series = comparable.map((r, idx) => { + const eq = r.result?.equity ?? []; + const base = eq[0]?.equity ?? 1; + const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b']; + return { + name: `Run ${r.id.slice(0, 6)}.`, + type: 'line' as const, + data: eq.map((p) => p.equity / base), + showSymbol: false, + lineStyle: { width: 1.5 }, + itemStyle: { color: colors[idx % colors.length] } + }; + }); + let xLen = Math.max(...comparable.map((r) => (r.result?.equity ?? []).length), 0); + const longest = comparable.reduce((best, r) => ((r.result?.equity ?? []).length > best.length ? r.result!.equity : best), [] as { date: string }[]); + return { + legend: { top: 0 }, + grid: { top: 32, left: 64, right: 20, bottom: 50 }, + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: longest.map((p) => p.date) }, + yAxis: { type: 'value', scale: true, name: '归一净值' }, + series + }; + }); + + const configDiffRows = $derived.by(() => { + if (selected.length < 2) return []; + const keys: (keyof Run)[] = ['project_id', 'version_id', 'dataset_id']; + const cfgKeys: (keyof NonNullable<Run['config']>)[] = ['capital', 'commission', 'slippage', 'benchmark_symbol']; + const rows: { label: string; vals: string[]; differing: boolean }[] = []; + const mk = (label: string, vals: unknown[]) => { + const valsStr = vals.map((v) => (v === null || v === undefined || v === '' ? '—' : v instanceof Date ? String(v) : String(v))); + rows.push({ label, vals: valsStr, differing: new Set(valsStr).size > 1 }); + }; + for (const k of keys) mk(k === 'project_id' ? '项目' : k === 'version_id' ? '代码版本' : '数据集', selected.map((r) => r[k])); + for (const k of cfgKeys) { + mk( + k === 'capital' ? '起始资金' : k === 'commission' ? '佣金率' : k === 'slippage' ? '滑点' : '基准', + selected.map((r) => r.config?.[k]) + ); + } + mk('状态', selected.map((r) => runStatusLabel[r.status])); + mk('时间', selected.map((r) => r.created_at)); + return rows; + }); +</script> + +<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} /> + + {#if !busy && !error && runs.length === 0} + <Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" /> + {/if} + + {#if !busy && !error && runs.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> + <th>资金/佣金/滑点</th> + <th>收益</th> + <th>最大回撤</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each runs as r (r.id)} + <tr> + <td> + <input + type="checkbox" + checked={chosenIds.includes(r.id)} + onchange={() => toggle(r.id)} + aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`} + disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)} + /> + </td> + <td>{fmtDateTime(r.created_at)}</td> + <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td> + <td class="num">{r.project_id?.slice(0, 8)}…</td> + <td class="num">{r.dataset_id?.slice(0, 8)}…</td> + <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td> + <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td> + <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td> + <td> + <a class="btn small" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</a> + </td> + </tr> + {/each} + </tbody> + </table> + <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p> + </div> + + {#if selected.length > 0} + <div class="card stack"> + <h2>条件差异</h2> + <table class="data"> + <thead> + <tr> + <th>维度</th> + {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each} + </tr> + </thead> + <tbody> + {#each configDiffRows as row, i (i)} + <tr class:diffrow={row.differing}> + <th>{row.label}</th> + {#each row.vals as v, j (j)} + <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td> + {/each} + </tr> + {/each} + </tbody> + </table> + </div> + + {#if comparable.length > 1} + <div class="card stack"> + <h2>收益曲线对齐比较(首日归一)</h2> + <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} /> + <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p> + </div> + {:else} + <Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" /> + {/if} + {/if} + {/if} + </div> +</Page> |
