summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectResultsTab.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/ProjectResultsTab.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/ProjectResultsTab.svelte')
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte299
1 files changed, 299 insertions, 0 deletions
diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte
new file mode 100644
index 0000000..4149213
--- /dev/null
+++ b/frontend/src/pages/ProjectResultsTab.svelte
@@ -0,0 +1,299 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import Chart from '../components/Chart.svelte';
+ import type { EChartsOption, LineSeriesOption } from 'echarts';
+import { listRuns, getRun } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import {
+ fmtDateTime,
+ fmtPct,
+ fmtNum,
+ formatMoney,
+ runStatusLabel,
+ runStatusBadge,
+ metricsLabels,
+ metricsRow
+ } from '../lib/format';
+ import type { Run, BacktestResult } from '../lib/types';
+
+ interface Props {
+ projectId: string;
+ runId?: string;
+ }
+ let { projectId, runId }: Props = $props();
+
+ let run = $state<Run | null>(null);
+ let busy = $state(true);
+ let error = $state<string | null>(null);
+ let history = $state<Run[]>([]);
+ let logsOpen = $state(false);
+ let ordersOpen = $state(false);
+ let chartMode = $state<'equity' | 'drawdown'>('equity');
+
+ $effect(() => {
+ void load();
+ });
+
+ async function load() {
+ busy = true;
+ error = null;
+ try {
+ if (!runId) {
+ const res = await listRuns(projectId);
+ history = res.items;
+ run = res.items[0] ?? null;
+ } else {
+ run = await getRun(runId);
+ }
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+
+ const result = $derived<BacktestResult | null>(run?.result ?? null);
+ const hasEquity = $derived(!!result && result.equity.length > 0);
+
+ const equityOption: EChartsOption = $derived.by(() => {
+ if (!result || !hasEquity) return {};
+ const dates = result.equity.map((p) => p.date);
+ const series: LineSeriesOption[] = [
+ {
+ name: "策略权益",
+ type: "line" as const,
+ data: result.equity.map((p) => p.equity),
+ showSymbol: false,
+ lineStyle: { width: 1.6 },
+ itemStyle: { color: "#0f766e" }
+ },
+ {
+ name: "现金",
+ type: "line" as const,
+ data: result.equity.map((p) => p.cash),
+ showSymbol: false,
+ lineStyle: { type: "dashed", opacity: 0.65 },
+ itemStyle: { color: "#94a3b8" }
+ }
+ ];
+ const bench = result.equity.map((p) => (p.benchmark === null || p.benchmark === undefined ? null : p.benchmark));
+ if (bench.some((x) => x !== null && x !== undefined)) {
+ series.push({
+ name: "基准",
+ type: "line" as const,
+ data: bench as number[],
+ showSymbol: false,
+ itemStyle: { color: "#eab308" }
+ });
+ }
+ return {
+ title: { text: undefined },
+ legend: { top: 0 },
+ grid: { top: 32, left: 60, right: 24, bottom: 54 },
+ tooltip: { trigger: "axis" },
+ xAxis: { type: "category", data: dates },
+ yAxis: { type: "value", scale: true, name: "金额" },
+ series
+ };
+ });
+
+ const drawdownOption: EChartsOption = $derived.by(() => {
+ if (!result || !hasEquity) return {};
+ const dates = result.equity.map((p) => p.date);
+ let peak = -Infinity;
+ const dd = result.equity.map((p) => {
+ peak = Math.max(peak, p.equity);
+ return -((peak - p.equity) / peak) * 100;
+ });
+ return {
+ grid: { top: 24, left: 60, right: 24, bottom: 54 },
+ tooltip: { trigger: "axis", valueFormatter: (v) => `${(v as number).toFixed(2)}%` },
+ xAxis: { type: "category", data: dates },
+ yAxis: { type: "value", name: "回撤 %", max: 0 },
+ series: [
+ {
+ name: "回撤",
+ type: "line" as const,
+ data: dd,
+ showSymbol: false,
+ areaStyle: { color: "rgba(30, 74, 66, 0.25)" },
+ lineStyle: { width: 1 },
+ itemStyle: { color: "#134e4a" }
+ }
+ ]
+ };
+ });
+
+ function switchToRun(id: string) {
+ runId = id;
+ void load();
+ }
+
+ const ml = $derived(metricsLabels());
+ const mr = $derived(result ? metricsRow(result.metrics) : []);
+</script>
+
+<div class="stack">
+ <div class="spread">
+ <h2>回测结果</h2>
+ {#if run}
+ <span class="row">
+ <span class="hint">Run {run.id.slice(0, 8)}…</span>
+ <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span>
+ </span>
+ {/if}
+ </div>
+
+ <Status busy={busy} error={error} busyText="加载结果…" empty={null} />
+
+ {#if !busy && !error && !run}
+ <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" />
+ {/if}
+
+ {#if run && !busy && !error}
+ {#if history.length > 1}
+ <div class="row">
+ <label for="runsel" class="hint" style="margin:0">查看其他回测:</label>
+ <select id="runsel" style="max-width:320px" value={run?.id ?? ""} onchange={(e) => switchToRun((e.target as HTMLSelectElement).value)}>
+ {#each history as h (h.id)}
+ <option value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</option>
+ {/each}
+ </select>
+ </div>
+ {/if}
+
+ {#if run.error}
+ <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div>
+ {/if}
+
+ {#if !result}
+ <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} />
+ {:else}
+ {#if result.warnings?.length}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <div>
+ <ul class="ul-plain">
+ {#each result.warnings as w, i (i)}<li>{w}</li>{/each}
+ </ul>
+ </div>
+ </div>
+ {/if}
+
+ <div class="card">
+ {#if result.metrics}
+ {@const mrow = result.metrics}
+ <div class="metric-grid">
+ <div class="metric">
+ <div class="mlabel">总收益率</div>
+ <div class="mvalue">{fmtPct(mrow.total_return)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">年化收益率</div>
+ <div class="mvalue">{fmtPct(mrow.annual_return)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">最大回撤</div>
+ <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">夏普比率</div>
+ <div class="mvalue">{fmtNum(mrow.sharpe)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">平仓回合数</div>
+ <div class="mvalue">{mrow.trade_count ?? "—"}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">期末权益</div>
+ <div class="mvalue">{formatMoney(mrow.final_equity)}</div>
+ </div>
+ </div>
+ {/if}
+ <p class="hint">
+ 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。
+ 引擎 {result.engine?.name} {result.engine?.version}
+ {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if}
+ {#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if}
+ </p>
+ </div>
+
+ <div class="card stack">
+ <div class="row">
+ <button class="btn small" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</button>
+ <button class="btn small" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</button>
+ </div>
+ {#if chartMode === "equity"}
+ <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} />
+ {:else}
+ <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} />
+ {/if}
+ </div>
+
+ <div class="card stack">
+ <h2>订单与持仓记录</h2>
+ <div class="cols-2">
+ <div>
+ <h3>成交记录 ({result.trades.length})</h3>
+ {#if result.trades.length > 0}
+ <div class="table-wrap">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>日期</th>
+ <th>标的</th>
+ <th>方向</th>
+ <th>数量</th>
+ <th>价格</th>
+ <th>金额</th>
+ <th>佣金</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each result.trades.slice(0, 80) as t, i (i)}
+ <tr>
+ <td>{t.date}</td>
+ <td class="num">{t.symbol}</td>
+ <td>{t.side === "buy" ? "买入" : "卖出"}</td>
+ <td class="num">{t.quantity}</td>
+ <td class="num">{fmtNum(t.price, 3)}</td>
+ <td class="num">{fmtNum(t.value)}</td>
+ <td class="num">{fmtNum(t.commission, 4)}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {#if result.trades.length > 80}
+ <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p>
+ {/if}
+ {:else}
+ <p class="hint">本区间未发生成交</p>
+ {/if}
+ </div>
+ <div>
+ <h3>策略日志</h3>
+ <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}>
+ {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`}
+ </button>
+ {#if logsOpen}
+ <pre class="logs">{result.logs.join("\n")}</pre>
+ {/if}
+ </div>
+ </div>
+ <div>
+ <h3>数据与引擎
+ <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span>
+ </h3>
+ <p class="hint">
+ 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。
+ </p>
+ </div>
+ </div>
+ {/if}
+ {/if}
+
+ {#if !busy && !error && history.length === 0 && run === null}
+ <Status busy={false} empty="还没有可展示的回测" emptyHint="在「回测」标签先启动一遍" />
+ {/if}
+</div>