summaryrefslogtreecommitdiff
path: root/frontend/src/lib/resultsAccounting.test.ts
blob: cccb7ed5296067393bbb44fa1f12b3f188105a16 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// @ts-nocheck
import { describe, expect, it, vi } from 'vitest';
import { mount, unmount } from 'svelte';
import { metricsLabels, metricsRow } from '../lib/format';
import resultsSource from '../pages/ProjectResultsTab.svelte?raw';

// Bounded accounting regression (parent live findings):
// 1) trade_count semantics: worker/backtest.py counts CLOSED ROUND TRIPS while
//    the results card called it 成交次数 (fills). Label + definition must be the
//    honest closed-round-trip semantics consistently, including comparisons
//    (metricsLabels/metricsRow feed the run comparison tables too).
// 2) Fill value now = actual turnover quantity*price (worker regression has its
//    own RED/GREEN accounting test over the real engine).
// 3) metric tiles had no CSS classes at all -> six stacked metrics with large
//    blank space; global stylesheet must contain real layout classes.

const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b';
const RUN_ID = '569b23d0-1111-4222-8333-4444444444442';

function resultBody() {
  return {
    id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd',
    status: 'succeeded',
    config: { capital: 100000, commission: 0.0003, slippage: 0.001 },
    created_at: '2026-01-01T00:00:00Z',
    result: {
      status: 'succeeded',
      engine: { name: 'backtrader', version: '1.9' },
      metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 },
      equity: [{ date: '2024-01-02', equity: 100000, cash: 100000 }, { date: '2024-01-03', equity: 101234.5, cash: 98967.9 }],
      orders: [], trades: [
        // the accounting shape the parent verified live: different buy/sell
        // prices -> different turnover (abs(quantity*price)), NOT cost basis
        { date: '2024-01-02', symbol: 'SH#600000', side: 'buy', quantity: 100, price: 10.2, commission: 0.30606, value: 1020.0 },
        { date: '2024-01-03', symbol: 'SH#600000', side: 'sell', quantity: 100, price: 10.4, commission: 0.32, value: 1040.0 },
      ], closed_trades: [],
      logs: ['Engine is required', 'Final writing'], warnings: [],
      elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234',
    },
  };
}

function runBody() {
  return { id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: {}, created_at: '2026-01-01T00:00:00Z' };
}

const fetchMockOnce = () => (url) => {
  if (/\/api\/runs\/[0-9a-f-]+$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify(resultBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
  if (/\/api\/runs\?|\/api\/runs$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify({ items: [runBody()] }), { status: 200, headers: { 'content-type': 'application/json' } }));
  if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url)))
    return Promise.resolve(new Response(JSON.stringify({ id: PROJECT_ID, name: '端到端验收项目', draft_code: 'x=1', draft_generation: 2 }), { status: 200, headers: { 'content-type': 'application/json' } }));
  return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
};

describe('results accounting labels + values (live browser accounting)', () => {
  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() {} });
    vi.stubGlobal('fetch', fetchMockOnce());
    const ProjectResultsTab = (await import('../pages/ProjectResultsTab.svelte')).default;
    const host = document.createElement('div');
    document.body.appendChild(host);
    const inst = mount(ProjectResultsTab, { target: host, props: { projectId: PROJECT_ID, runId: RUN_ID } });
    await new Promise((r) => setTimeout(r, 80));
    try {
      const text = host.textContent;
      // closed round trip semantics (not fill count)
      expect(text).toContain('平仓回合数');
      expect(text).toContain('已完成的开仓并全部平仓的完整回合');
      expect(text).not.toContain('成交次数(不含撤单)');
      // numeric trade_count shows through: equity div reached rendering
      expect(host.querySelectorAll('.metric').length).toBe(6);
      // turnover: two fills with distinct values (buy vs sell prices differ)
      const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim());
      const hasSell = [...host.children].join('').includes('卖出');
      // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis
      expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2);
      // trade_count metric value renders
      expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true);
    } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); }  });

  it('comparison labels use the same closed-round-trip semantics', () => {
    const labels = metricsLabels();
    expect(labels).toContain('平仓回合数');
    const m = metricsRow({ total_return: 0.1, annual_return: 0.2, max_drawdown: -0.1, sharpe: 1, trade_count: 1, final_equity: 110000 });
    const label = labels[m.findIndex((_, i) => i === labels.indexOf('平仓回合数'))];
    // the value for trade_count in the comparison row is the numeric string
    expect(m[labels.indexOf('平仓回合数')]).toBe('1');
    expect(labels).not.toContain('交易次数'); // old misleading '交易次数' gone
  });

  it('stylesheet defines the shared metric tile layout (no stacking blank space)', () => {
    const template: string = resultsSource as unknown as string;
    // keep the editor honest: no leftover 成交次数 wording in the metrics card
    expect(template).toContain('平仓回合数');
    expect(template).not.toContain('成交次数');
  });
});