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
|
<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 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));
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>
<Button size="sm" variant="outline" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</Button>
</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 data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} />
<p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
</div>
{:else}
<Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" />
{/if}
{/if}
{/if}
</div>
</Page>
|