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 | |
| 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)。
| -rw-r--r-- | frontend/src/components/DatasetCard.svelte | 169 | ||||
| -rw-r--r-- | frontend/src/lib/datasetCardRetry.test.ts | 113 | ||||
| -rw-r--r-- | frontend/src/lib/datasetCardWarnings.test.ts | 91 | ||||
| -rw-r--r-- | frontend/src/lib/projectDataTabRecreated.test.ts | 19 | ||||
| -rw-r--r-- | frontend/src/lib/resultsAccounting.test.ts | 2 | ||||
| -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 |
9 files changed, 444 insertions, 161 deletions
diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte index 7b38874..2d49e65 100644 --- a/frontend/src/components/DatasetCard.svelte +++ b/frontend/src/components/DatasetCard.svelte @@ -1,15 +1,24 @@ <script lang="ts"> import Status from './Status.svelte'; - import { getDataset, getDatasetPreview } from '../lib/client'; + import { getDataset, getDatasetPreview, createDataset } from '../lib/client'; import { extractStatusMessage } from '../lib/state'; import { adjustmentLabel, assetTypeLabel, instrumentLabel, datasetStatusLabel, fmtDate } from '../lib/format'; import { summarizeCoverage } from '../lib/coverage'; import type { Dataset, DatasetPreview } from '../lib/types'; + import { Button } from '$lib/components/ui/button'; + import * as Card from '$lib/components/ui/card'; + import * as Table from '$lib/components/ui/table'; + import { Badge } from '$lib/components/ui/badge'; + import { Alert, AlertTitle, AlertDescription } from '$lib/components/ui/alert'; interface Props { dsId: string; + /** called after a failed dataset is re-requested so the parent list can + * reload and show the new dataset (which then self-tracks pending→ready| + * failed via the parent's polling). */ + onRecreated?: (d: Dataset) => void; } - let { dsId }: Props = $props(); + let { dsId, onRecreated }: Props = $props(); let ds = $state<Dataset | null>(null); let error: string | null = $state(null); @@ -27,6 +36,46 @@ } } + let recreateBusy = $state(false); + let recreateErr: string | null = $state(null); + let recreatedId: string | null = $state(null); + + /// Failed datasets are terminal (no retry endpoint exists): recreate via the + /// normal create API using the exact stored request, while the original + /// failed record stays untouched and visible. + async function recreateFromRequest() { + // ds.request is the canonical request WITHOUT a dataset name: preserve the + // user-visible name from ds.name instead (leader review 2026-09-17). + if (!ds?.request || recreateBusy || recreatedId) return; + recreateBusy = true; + recreateErr = null; + recreatedId = null; + try { + const body = { + name: ds.name ?? undefined, + instruments: (ds.request.instruments ?? []).map((i) => ({ + symbol: i.symbol, + market: i.market, + asset_type: i.asset_type, + name: i.name ?? null, + currency: null + })), + start_date: ds.request.start_date, + end_date: ds.request.end_date, + frequency: ds.request.frequency, + adjustment: ds.request.adjustment, + fields: [...(ds.request.fields ?? [])] + }; + const created = await createDataset(body); + recreatedId = created.id; + onRecreated?.(created); + } catch (e) { + recreateErr = `重新获取失败:${extractStatusMessage(e)}`; + } finally { + recreateBusy = false; + } + } + $effect(() => { void load(); return stopPolling; @@ -68,37 +117,63 @@ } </script> -<div class="card"> +<Card.Root> {#if ds} <div class="spread"> - <div> + <div class="min-w-0"> <strong>{ds.name ?? '未命名数据集'}</strong> {#if ds.manifest_hash}<span class="hint mono">manifest {ds.manifest_hash.slice(0, 10)}…</span>{/if} - {#if ds.cache_hit}<span class="badge teal">缓存命中 · 复用现有物化对象</span>{/if} + {#if ds.cache_hit}<Badge variant="secondary">缓存命中 · 复用现有物化对象</Badge>{/if} </div> - <span class="badge {ds.status === 'ready' ? 'ok' : ds.status === 'failed' ? 'danger' : 'warn'}"> + <Badge variant={ds.status === 'ready' ? 'secondary' : ds.status === 'failed' ? 'destructive' : 'outline'} + class={ds.status === 'ready' ? 'badge ok' : ds.status === 'failed' ? 'badge danger' : 'badge warn'}> {datasetStatusLabel[ds.status]} - </span> + </Badge> + </div> + <div class="table-wrap"> + <Table.Root> + <Table.Body> + <Table.Row> + <Table.Cell class="k">请求标的</Table.Cell> + <Table.Cell>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell class="k">范围</Table.Cell> + <Table.Cell>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell class="k">字段</Table.Cell> + <Table.Cell>{(ds.request.fields ?? []).join(', ')}</Table.Cell> + </Table.Row> + <Table.Row> + <Table.Cell class="k">创建时间</Table.Cell> + <Table.Cell>{fmtDate(ds.created_at)}</Table.Cell> + </Table.Row> + </Table.Body> + </Table.Root> </div> - <table class="data kv"> - <tbody> - <tr><th>请求标的</th><td>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</td></tr> - <tr><th>范围</th><td>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</td></tr> - <tr><th>字段</th><td>{(ds.request.fields ?? []).join(', ')}</td></tr> - <tr><th>创建时间</th><td>{fmtDate(ds.created_at)}</td></tr> - </tbody> - </table> {#if ds.status === 'failed'} <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></div> - {/if} - {#if ds.status !== 'ready'} - <p class="hint">{ds.status === 'pending' || ds.status === 'running' ? '获取中,完成后可预览覆盖情况' : ''}</p> + <div class="retry-row"> + <Button variant="outline" size="sm" + disabled={recreateBusy || recreatedId !== null} + onclick={() => void recreateFromRequest()} + > + {recreateBusy ? '重新获取中…' : '使用相同请求重新获取'} + </Button> + {#if recreatedId} + <span class="hint"> + 已用原请求创建新数据集(编号 {recreatedId.slice(0, 8)}…),新数据集出现在下方列表并自动跟踪进度;本条失败记录原样保留。 + </span> + {/if} + {#if recreateErr}<span role="alert">{recreateErr}</span>{/if} + </div> {/if} {#if ds.status === 'ready'} <div> - <button class="btn small" aria-expanded={showPreview} onclick={togglePreview}> + <Button variant="outline" size="sm" aria-expanded={showPreview} onclick={togglePreview}> {showPreview ? '收起预览' : '查看数据覆盖预览'} - </button> + </Button> </div> {#if showPreview} <div class="preview-box"> @@ -111,30 +186,40 @@ <ul class="cov-list"> {#each cov.warnings as w, i (i)}<li>{w}</li>{/each} </ul> - {#if (preview.coverage ?? []).some((c) => c.warnings)} - <ul class="cov-list"> - {#each preview.coverage.filter((c) => c.warnings) as c (c.instrument)} - <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li> - {/each} - </ul> - {/if} </div></div> - {:else if cov.common.start} + {/if} + {#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)} + <div class="banner warn" role="alert"><span aria-hidden="true">⚠</span><div> + <div class="cov-caption">来源与口径说明:</div> + <ul class="cov-list"> + {#each preview.coverage.filter((c) => (c.warnings ?? []).length > 0) as c (c.instrument)} + <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li> + {/each} + </ul> + </div></div> + {/if} + {#if cov.common.start && cov.warnings.length === 0} <div class="banner info"> <span aria-hidden="true">✓</span> <span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span> </div> {/if} - <table class="data scrollx"> - <thead> - <tr>{#each preview.columns as c (c)}<th>{c}</th>{/each}</tr> - </thead> - <tbody> - {#each preview.rows as row, i (i)} - <tr>{#each preview.columns as c (c)}<td class="num">{row[c]}</td>{/each}</tr> - {/each} - </tbody> - </table> + <div class="scrollx"> + <Table.Root> + <Table.Header> + <Table.Row> + {#each preview.columns as c (c)}<Table.Head>{c}</Table.Head>{/each} + </Table.Row> + </Table.Header> + <Table.Body> + {#each preview.rows as row, i (i)} + <Table.Row> + {#each preview.columns as c (c)}<Table.Cell class="num">{row[c]}</Table.Cell>{/each} + </Table.Row> + {/each} + </Table.Body> + </Table.Root> + </div> {#if preview.truncated} <p class="hint">预览仅展示部分行</p> {/if} @@ -145,13 +230,15 @@ {:else} <Status busy={busy} error={error} busyText="载入数据集…" empty={null} /> {/if} -</div> +</Card.Root> <style> - .kv th { width: 110px; color: var(--text-2); font-weight: 500; min-width: 110px; } + .table-wrap :global(td:first-child) { width: 92px; min-width: 92px; color: var(--text-2); font-weight: 500; } .mono { font-family: var(--mono); margin-left: 0.5rem; } .preview-box { margin-top: 0.5rem; display: grid; gap: 0.5rem; } - .scrollx { display: block; overflow-x: auto; } + .scrollx { display: block; overflow-x: auto; min-width: 0; } .cov-caption { font-weight: 600; } .cov-list { margin: 0.2rem 0 0 1.1rem; padding: 0; } + .retry-row { margin-top: 0.4rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; min-width: 0; } + .retry-row .hint { min-width: 0; overflow-wrap: anywhere; } </style> diff --git a/frontend/src/lib/datasetCardRetry.test.ts b/frontend/src/lib/datasetCardRetry.test.ts new file mode 100644 index 0000000..df9dc31 --- /dev/null +++ b/frontend/src/lib/datasetCardRetry.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; + +vi.mock('../lib/client', () => { + const real = vi.importActual<typeof import('../lib/client')>('../lib/client'); + return { + ...real, + getDataset: vi.fn(), + getDatasetPreview: vi.fn(), + createDataset: vi.fn() + }; +}); + +import DatasetCard from '../components/DatasetCard.svelte'; +import { getDataset, createDataset } from '../lib/client'; +import type { Dataset } from '../lib/types'; + +const failedDs: Dataset = { + id: 'ds-failed-1', + name: '国泰自由现金流-20251231-20260917', + request: { + name: '国泰自由现金流-20251231-20260917', + instruments: [ + { symbol: '159399', market: 'SZ', asset_type: 'etf', name: '国泰自由现金流', currency: null } + ], + start_date: '2025-12-31', + end_date: '2026-09-17', + frequency: 'daily', + adjustment: 'none', + fields: ['open', 'high', 'low', 'close', 'volume'] + }, + status: 'failed', + error: '159399: eastmoney failed (RemoteDisconnected); tencent fallback failed (tencent source has no listed-ETF daily adapter)', + created_at: '2026-09-17T01:00:00Z' +}; + +beforeEach(() => { + vi.mocked(getDataset).mockResolvedValue(failedDs); + vi.mocked(createDataset).mockReset(); +}); + +afterEach(() => cleanup()); + +describe('failed dataset retry affordance (159399 regression: user could never re-request)', () => { + it('failed dataset offers recreate-with-same-request button and original error stays visible', async () => { + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + await screen.findByText(/eastmoney failed/); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + expect(btn).toBeTruthy(); + }); + + it('recreate preserves ds.name (canonical request has no name) via the normal create API and keeps the failed record untouched', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-2', + status: 'pending', + error: null + }); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await waitFor(() => { + expect(createDataset).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(createDataset).mock.calls[0][0]).toStrictEqual({ + ...failedDs.request, + name: failedDs.name, + instruments: [{ ...failedDs.request.instruments[0], currency: null }] + }); + // new dataset becomes visible/trackable: hint points to the list below + await screen.findByText(/新数据集出现在下方列表并自动跟踪进度/); + }); + + it('recreate button is disabled after a successful recreate (no duplicate submissions), error clears button state instead', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-3', + status: 'pending', + error: null + }); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + expect((btn as HTMLButtonElement).disabled).toBe(false); + await fireEvent.click(btn); + await screen.findByText(/已用原请求创建新数据集/); + const done = screen.getByRole('button', { name: /使用相同请求重新获取/ }); + expect((done as HTMLButtonElement).disabled).toBe(true); + }); + + it('onRecreated callback fires with the new dataset so parents can refresh', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-4', + status: 'pending', + error: null + }); + const onRecreated = vi.fn(); + render(DatasetCard, { props: { dsId: 'ds-failed-1', onRecreated } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await screen.findByText(/已用原请求创建新数据集/); + expect(onRecreated).toHaveBeenCalledTimes(1); + expect(onRecreated.mock.calls[0][0].id).toBe('ds-new-4'); + }); + + it('recreate failure surfaces the error message instead of a silent no-op', async () => { + vi.mocked(createDataset).mockRejectedValue(new Error('HTTP400: validation')); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await screen.findByText(/重新获取失败/); + }); +}); diff --git a/frontend/src/lib/datasetCardWarnings.test.ts b/frontend/src/lib/datasetCardWarnings.test.ts new file mode 100644 index 0000000..8e79e84 --- /dev/null +++ b/frontend/src/lib/datasetCardWarnings.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; + +vi.mock('../lib/client', () => { + const real = vi.importActual<typeof import('../lib/client')>('../lib/client'); + return { + ...real, + getDataset: vi.fn(), + getDatasetPreview: vi.fn(), + createDataset: vi.fn() + }; +}); + +import DatasetCard from '../components/DatasetCard.svelte'; +import { getDataset, getDatasetPreview } from '../lib/client'; +import type { Dataset, DatasetPreview } from '../lib/types'; + +const readyDs: Dataset = { + id: 'ds-ready-1', + name: '国泰自由现金流-20251231-20260917', + request: { + instruments: [ + { symbol: '159399', market: 'SZ', asset_type: 'etf', name: '国泰自由现金流ETF', currency: null } + ], + start_date: '2025-12-31', + end_date: '2026-09-17', + frequency: 'daily', + adjustment: 'none', + fields: ['open', 'high', 'low', 'close', 'volume'] + }, + status: 'ready', + created_at: '2026-09-17T01:00:00Z' +}; + +const preview: DatasetPreview = { + columns: ['date', 'open', 'high', 'low', 'close', 'volume'], + rows: [ + { date: '2026-09-16', open: 1.001, high: 1.001, low: 0.982, close: 0.992, volume: 144526400 } + ], + coverage: [ + { + instrument: '159399', + market: 'SZ', + asset_type: 'etf', + requested_start: '2025-12-31', + requested_end: '2026-09-17', + actual_start: '2025-12-31', + actual_end: '2026-09-16', + row_count: 173, + warnings: [ + 'sina volume unit is 股 (shares)', + 'provider_fallback: eastmoney attempt failed; served by sina', + 'actual coverage 2025-12-31..2026-09-16 differs from request; gaps kept' + ] + } + ], + warnings: [] +}; + +beforeEach(() => { + vi.mocked(getDataset).mockResolvedValue(readyDs); + vi.mocked(getDatasetPreview).mockReset(); +}); + +afterEach(() => cleanup()); + +describe('per-instrument provenance warnings must be visible even without the range warning banner (159399 units/provider_fallback disclosure)', () => { + it('renders 夲-instrument sina volume-unit and provider_fallback warnings even when the common range banner shows 覆盖平稳', async () => { + vi.mocked(getDatasetPreview).mockResolvedValue(preview); + render(DatasetCard, { props: { dsId: 'ds-ready-1' } }); + const btn = await screen.findByRole('button', { name: /查看数据覆盖预览/ }); + await fireEvent.click(btn); + await waitFor(() => { + expect(screen.getByText(/provider_fallback/)).toBeTruthy(); + expect(screen.getByText(/volume unit is 股/)).toBeTruthy(); + expect(screen.getByText(/differs from request/)).toBeTruthy(); + }); + }); + + it('no warnings: shows the plain 覆盖平稳 banner and no warning list', async () => { + vi.mocked(getDatasetPreview).mockResolvedValue({ + ...preview, + coverage: [{ ...preview.coverage[0], warnings: [] }] + }); + render(DatasetCard, { props: { dsId: 'ds-ready-1' } }); + const btn = await screen.findByRole('button', { name: /查看数据覆盖预览/ }); + await fireEvent.click(btn); + await screen.findByText(/覆盖平稳/); + expect(screen.queryByText(/provider_fallback/)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/projectDataTabRecreated.test.ts b/frontend/src/lib/projectDataTabRecreated.test.ts new file mode 100644 index 0000000..1a922c2 --- /dev/null +++ b/frontend/src/lib/projectDataTabRecreated.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +// leader review regression (2026-09-17): the recreated dataset previously +// stayed invisible — the parent (ProjectDataTab) never reloaded, the user +// remained on the failed card. The wiring must exist AND the callback must be +// the parent's own load (so pending→ready/failed tracking resumes). +import dataTabSource from '../pages/ProjectDataTab.svelte?raw'; +import wizardSource from '../components/DatasetWizard.svelte?raw'; + +describe('ProjectDataTab reloads when a failed dataset is re-requested', () => { + it('DatasetCard receives onRecreated bound to the parent reload', () => { + const src: string = dataTabSource as unknown as string; + expect(src).toMatch(/onRecreated=\{\(\) => void load\(\)\}/); + }); + + it('dataset card posts the create through the same client API the wizard uses', () => { + const src: string = wizardSource as unknown as string; + expect(src).toMatch(/createDataset/); + }); +}); diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts index 226e1d0..cccb7ed 100644 --- a/frontend/src/lib/resultsAccounting.test.ts +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -53,7 +53,7 @@ const fetchMockOnce = () => (url) => { }; describe('results accounting labels + values (live browser accounting)', () => { - it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', async () => { + it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', { timeout: 20000 }, async () => { // jsdom lacks ResizeObserver; Chart.svelte needs it only for responsive // canvas resize, which jsdom never triggers. Narrow environment shim. vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); 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} |
