diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:57 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:57 +0800 |
| commit | 45ecb4e9dfe225bc982e9af9315590bcb0783df7 (patch) | |
| tree | e046f42ace89c09d60aa4f07db8450507364fdb5 /frontend/src/pages | |
| parent | 33cac3eef7d9962d8803cd812553549d6f555516 (diff) | |
| download | strategy-lab-45ecb4e9dfe225bc982e9af9315590bcb0783df7.tar.gz | |
feat(frontend): 结果/实验/策略/数据页迁移 shadcn 与新 Chart API
[变更性质]
四个业务标签页与数据集卡片迁移 shadcn 组件,图表消费切换到
layerchart 长格式 API,功能与数据语义不变。
[新增功能]
结果页权益曲线(策略权益/现金/基准,基准可缺省)与回撤曲线(desc
面积)改由 Chart.svelte data/series 渲染;实验记录页多回测归一对比
曲线改为快速长格式行构造。空数据行允许 undefined 列(基准缺口),
Chart data 类型放宽 [key:string]: string|number|undefined。
[实现方案]
RunsPage:查看入口改 shadcn Button(href 语义保留 hash 路由),
对比图数据由 compareOption(echarts option)化为 compare。
ProjectResultsTab:图表开关、日志折叠用 Button;equityData/
drawdownData $derived 计算峰值回撤。ProjectStrategyTab:快照输入、
冲突/AI/版本操作迁移 Button/Input。ProjectDataTab 与 DatasetCard:
shadcn 组件 + 保留 banner error/warn/info、retry-row 等 legacy class,
"使用相同请求重新获取"重试入口与 onRecreated 逻辑不变,配套
datasetCardRetry/datasetCardWarnings/projectDataTabRecreated 回归
测试。resultsAccounting.test.ts:layerchart 在 jsdom 挂载约 3 秒,
suite 全量下易撞默认 5s 超时,首测提高至 20s。
[影响范围]
npm run check 0 error、58 tests 全绿、npm run build 通过;真浏览器
375/768/1440 图表页零溢出(artifacts/ui-shadcn 截图与探针 JSON)。
Diffstat (limited to 'frontend/src/pages')
| -rw-r--r-- | frontend/src/pages/ProjectDataTab.svelte | 9 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectResultsTab.svelte | 108 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectStrategyTab.svelte | 35 | ||||
| -rw-r--r-- | frontend/src/pages/RunsPage.svelte | 59 |
4 files changed, 92 insertions, 119 deletions
diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte index e3b1923..284aec7 100644 --- a/frontend/src/pages/ProjectDataTab.svelte +++ b/frontend/src/pages/ProjectDataTab.svelte @@ -5,6 +5,7 @@ import { listDatasets } from '../lib/client'; import { extractStatusMessage } from '../lib/state'; import type { Dataset } from '../lib/types'; + import { Button } from '$lib/components/ui/button'; interface Props { projectId?: string; @@ -17,7 +18,7 @@ let error: string | null = $state(null); // svelte-ignore state_referenced_locally let wizardOpen = $state(showWizard); - + let refreshTimer: ReturnType<typeof setInterval> | null = null; @@ -61,9 +62,9 @@ <div class="stack"> {#if wizardOpen} <DatasetWizard onCreated={() => void load()} /> - <button class="btn ghost" onclick={() => (wizardOpen = false)}>收起新建面板</button> + <Button variant="ghost" size="sm" onclick={() => (wizardOpen = false)}>收起新建面板</Button> {:else} - <button class="btn primary" onclick={() => (wizardOpen = true)}>新建数据集</button> + <Button size="sm" class="w-fit" onclick={() => (wizardOpen = true)}>新建数据集</Button> {/if} <h2>我的数据集</h2> @@ -79,7 +80,7 @@ {#if !busy && !error && items.length > 0} {#each items as ds (ds.id)} - <DatasetCard dsId={ds.id} /> + <DatasetCard dsId={ds.id} onRecreated={() => void load()} /> {/each} {/if} </div> diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte index 4149213..5ff03c0 100644 --- a/frontend/src/pages/ProjectResultsTab.svelte +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -1,8 +1,8 @@ <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 { Button } from '$lib/components/ui/button'; + import { listRuns, getRun } from '../lib/client'; import { extractStatusMessage } from '../lib/state'; import { fmtDateTime, @@ -55,73 +55,42 @@ import { listRuns, getRun } from '../lib/client'; 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" } - } + // long-format rows for the layerchart renderer ({ i, date, ...columns }) + const equityData = $derived.by(() => { + if (!result || !hasEquity) return []; + return result.equity.map((p, i) => ({ + i, + date: p.date, + equity: p.equity, + cash: p.cash, + benchmark: p.benchmark ?? undefined + })); + }); + + const hasBench = $derived(equityData.some((r) => r.benchmark !== undefined && r.benchmark !== null)); + + const equitySeries = $derived.by(() => { + if (!result || !hasEquity) return []; + const s = [ + { key: 'equity', label: '策略权益', value: 'equity', color: '#0f766e' }, + { key: 'cash', label: '现金', value: 'cash', color: '#94a3b8', dashed: true } ]; - 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 - }; + if (hasBench) s.push({ key: 'benchmark', label: '基准', value: 'benchmark', color: '#eab308' }); + return s; }); - const drawdownOption: EChartsOption = $derived.by(() => { - if (!result || !hasEquity) return {}; - const dates = result.equity.map((p) => p.date); + const drawdownData = $derived.by(() => { + if (!result || !hasEquity) return []; let peak = -Infinity; - const dd = result.equity.map((p) => { + return result.equity.map((p, i) => { peak = Math.max(peak, p.equity); - return -((peak - p.equity) / peak) * 100; + return { i, date: p.date, dd: -((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" } - } - ] - }; + }); + + const drawdownSeries = $derived.by(() => { + if (!result || !hasEquity) return []; + return [{ key: 'dd', label: '回撤', value: 'dd', color: '#134e4a', area: true }]; }); function switchToRun(id: string) { @@ -220,13 +189,13 @@ import { listRuns, getRun } from '../lib/client'; <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> + <Button variant={chartMode === "equity" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</Button> + <Button variant={chartMode === "drawdown" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</Button> </div> {#if chartMode === "equity"} - <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} /> + <Chart data={equityData} series={equitySeries} ariaLabel="策略权益曲线" height={340} /> {:else} - <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} /> + <Chart data={drawdownData} series={drawdownSeries} ariaLabel="回撤曲线" height={300} /> {/if} </div> @@ -273,10 +242,9 @@ import { listRuns, getRun } from '../lib/client'; </div> <div> <h3>策略日志</h3> - <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> + <Button variant="outline" size="sm" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`} - </button> - {#if logsOpen} + </Button> {#if logsOpen} <pre class="logs">{result.logs.join("\n")}</pre> {/if} </div> diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte index 5f2e242..0df9f1a 100644 --- a/frontend/src/pages/ProjectStrategyTab.svelte +++ b/frontend/src/pages/ProjectStrategyTab.svelte @@ -3,6 +3,8 @@ import CodeEditor from '../components/CodeEditor.svelte'; import DiffView from '../components/DiffView.svelte'; import Modal from '../components/Modal.svelte'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; import { projectsStore } from '../lib/listsStore.svelte'; import { getProject, @@ -274,7 +276,7 @@ </p> <CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} /> <div class="spread wrap"> - <input + <Input bind:value={snapshotMessage} placeholder="快照说明,例如:加入RSI过滤器(可选)" style="flex:2 1 240px" @@ -282,16 +284,16 @@ if (ev.key === 'Enter') void snapshot(); }} /> - <button class="btn" disabled={snapshotBusy} onclick={snapshot}> + <Button disabled={snapshotBusy} onclick={snapshot}> {snapshotBusy ? '创建快照…' : '保存版本快照'} - </button> - <button - class="btn" + </Button> + <Button + variant="outline" onclick={() => versions[1] && void compareWithDraft(versions[1])} disabled={!versions[1]} > 草稿与上一快照对比 - </button> + </Button> </div> <Status busy={false} error={snapshotErr} empty={snapshotMsg} /> @@ -304,8 +306,8 @@ </div> <DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" /> <div class="row"> - <button class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button> - <button class="btn" onclick={loadServer}>采用服务器版本</button> + <Button onclick={keepMine}>保留本机修改(覆盖服务器)</Button> + <Button variant="outline" onclick={loadServer}>采用服务器版本</Button> </div> {/if} </div> @@ -316,9 +318,9 @@ <label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label> <textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea> <div class="row"> - <button class="btn primary" disabled={aiBusy || !instruction.trim()} onclick={askAI}> + <Button disabled={aiBusy || !instruction.trim()} onclick={askAI}> {aiBusy ? 'AI 处理中…' : '获取建议'} - </button> + </Button> </div> {#if aiErr} <div class="banner error" role="alert"> @@ -341,15 +343,14 @@ <h3>与当前草稿的差异</h3> <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" /> <div class="row"> - <button - class="btn primary" + <Button disabled={aiAcceptBusy} onclick={acceptAI} title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''} > {aiAcceptBusy ? '接受中…' : '接受为新版本'} - </button> - <button class="btn ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</button> + </Button> + <Button variant="ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</Button> {#if aiResult.base_generation !== baseGeneration} <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span> {/if} @@ -395,11 +396,11 @@ <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td> <td> <div class="row"> - <button class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button> + <Button variant="outline" size="sm" onclick={() => void compareWithDraft(v)}>对比当前</Button> {#if i + 1 < versions.length} - <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button> + <Button variant="outline" size="sm" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</Button> {/if} - <button class="btn small" onclick={() => void restore(v)}>恢复为新草稿</button> + <Button size="sm" onclick={() => void restore(v)}>恢复为新草稿</Button> </div> </td> </tr> diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte index bd77892..154fd67 100644 --- a/frontend/src/pages/RunsPage.svelte +++ b/frontend/src/pages/RunsPage.svelte @@ -2,7 +2,7 @@ import Page from '../components/Page.svelte'; import Status from '../components/Status.svelte'; import Chart from '../components/Chart.svelte'; - import type { EChartsOption } from 'echarts'; + 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'; @@ -40,31 +40,34 @@ 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 - }; + // 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(() => { @@ -133,7 +136,7 @@ <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> + <Button size="sm" variant="outline" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</Button> </td> </tr> {/each} @@ -168,7 +171,7 @@ {#if comparable.length > 1} <div class="card stack"> <h2>收益曲线对齐比较(首日归一)</h2> - <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} /> + <Chart data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} /> <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p> </div> {:else} |
