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/ProjectResultsTab.svelte | |
| download | strategy-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.svelte | 299 |
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> |
