summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/DatasetCard.svelte169
-rw-r--r--frontend/src/lib/datasetCardRetry.test.ts113
-rw-r--r--frontend/src/lib/datasetCardWarnings.test.ts91
-rw-r--r--frontend/src/lib/projectDataTabRecreated.test.ts19
-rw-r--r--frontend/src/lib/resultsAccounting.test.ts2
-rw-r--r--frontend/src/pages/ProjectDataTab.svelte9
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte108
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte35
-rw-r--r--frontend/src/pages/RunsPage.svelte59
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}