summaryrefslogtreecommitdiff
path: root/frontend/src/pages/RunsPage.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/RunsPage.svelte
downloadstrategy-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.svelte180
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>