1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
<script lang="ts">
import Page from '../components/Page.svelte';
import Status from '../components/Status.svelte';
import Chart from '../components/Chart.svelte';
import { Button } from '$lib/components/ui/button';
import { listRuns } from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format';
import { capitalReporting } from '../lib/capitalMetrics';
import { Alert } from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
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];
}
// long-format rows for the layerchart renderer: one row per index, one
// column per selected run, first-day-normalized equity
const compare = $derived.by(() => {
if (comparable.length === 0) return { data: [], series: [] };
const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b'];
const flat = comparable.map((r, i) => ({
run: r,
eq: r.result?.equity ?? [],
key: `s${i}`,
color: colors[i % colors.length]
}));
const len = Math.max(0, ...flat.map((f) => f.eq.length));
const data: { [key: string]: string | number; i: number }[] = [];
for (let i = 0; i < len; i++) {
const row: { [key: string]: string | number; i: number } = { i, date: flat[0].eq[i]?.date ?? String(i) };
for (const f of flat) {
const base = f.eq[0]?.equity ?? 1;
if (f.eq[i]) row[f.key] = f.eq[i].equity / base;
}
data.push(row);
}
const series = flat.map((f) => ({
key: f.key,
label: `Run ${f.run.id.slice(0, 6)}.`,
value: f.key,
color: f.color
}));
return { data, 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));
// 投入口径 reporting(任务 A):与结果页同口径,不含初始闲置现金
const caps = selected.map((r) => (r.result ? capitalReporting(r.result) : null));
mk('累计投入', caps.map((c) => (c ? formatMoney(c.invested) : '—')));
mk('持仓估值', caps.map((c) => (c ? formatMoney(c.holdingValue) : '—')));
mk('投入回报率', caps.map((c) => (c ? (c.returnPct ?? '—') : '—')));
return rows;
});
</script>
<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide>
<div class="flex flex-col gap-4 md:gap-6">
{#if busy}
<Status busy skeletonRows={4} busyText="正在加载实验…" empty={null} />
{:else if error}
<Status busy={false} {error} empty={null}>
{#snippet errorCta()}
<Button onclick={load}>重新加载</Button>
{/snippet}
</Status>
{:else if runs.length === 0}
<Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" />
{/if}
{#if !busy && !error && runs.length > 0}
<Card.Root size="sm" class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head></Table.Head>
<Table.Head>时间</Table.Head>
<Table.Head>状态</Table.Head>
<Table.Head>项目</Table.Head>
<Table.Head>数据集</Table.Head>
<Table.Head>资金/佣金/滑点</Table.Head>
<Table.Head>收益</Table.Head>
<Table.Head>最大回撤</Table.Head>
<Table.Head>操作</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each runs as r (r.id)}
<Table.Row>
<Table.Cell>
<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)}
/>
</Table.Cell>
<Table.Cell>{fmtDateTime(r.created_at)}</Table.Cell>
<Table.Cell><Badge variant={runStatusBadge[r.status] === 'ok' ? 'secondary' : runStatusBadge[r.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[r.status]}</Badge></Table.Cell>
<Table.Cell class="tabular-nums">{r.project_id?.slice(0, 8)}…</Table.Cell>
<Table.Cell class="tabular-nums">{r.dataset_id?.slice(0, 8)}…</Table.Cell>
<Table.Cell>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</Table.Cell>
<Table.Cell>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</Table.Cell>
<Table.Cell>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</Table.Cell>
<Table.Cell>
<Button size="sm" variant="outline" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<p class="text-xs text-muted-foreground p-4">最多同时选中 4 个已完成的回测用于比较。</p>
</Card.Root>
{#if selected.length > 0}
<Card.Root size="sm" class="p-4 flex flex-col gap-4 overflow-x-auto">
<h2 class="text-lg font-semibold m-0">条件差异</h2>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>维度</Table.Head>
{#each selected as r (r.id)}<Table.Head>Run {r.id.slice(0, 6)}.</Table.Head>{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each configDiffRows as row, i (i)}
<Table.Row class={row.differing ? 'bg-primary/5' : ''}>
<Table.Head>{row.label}</Table.Head>
{#each row.vals as v, j (j)}
<Table.Cell class="tabular-nums">{v}</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Root>
{#if comparable.length > 1}
<Card.Root size="sm" class="p-4 flex flex-col gap-4">
<h2 class="text-lg font-semibold m-0">收益曲线对齐比较(首日归一)</h2>
<Chart data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} />
<p class="text-xs text-muted-foreground">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
</Card.Root>
{:else}
<Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" />
{/if}
{/if}
{/if}
</div>
</Page>
|