From f3831e6cc62938c94b398e93e9f25e6f22164266 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 10:36:09 +0800 Subject: feat(frontend): 投入口径 reporting 推导函数(资本三件套:投入/持仓估值/回报率) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] 新增纯函数推导层,任务 A3。 [新增功能] capitalReporting:累计投入=首日cash−末日cash(2位);持仓估值=Σ末日positions×closes;投入回报率=估值/投入−1(带正负号百分号);未投入/无数据返回 null 不除零。 [实现方案] 纯前端推导,零后端改动;测试锚点取现网 run 26e9c9ab(投入 12029.09 / 估值 10846.00 / -9.84%)真实账目,另有多 symbol/未建仓/空数据等 9 用例。 [影响范围] frontend/src/lib/capitalMetrics.ts + 新增测试,vitest 84/84。 --- frontend/src/lib/capitalMetrics.test.ts | 72 +++++++++++++++++++++++++++++++++ frontend/src/lib/capitalMetrics.ts | 46 +++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 frontend/src/lib/capitalMetrics.test.ts create mode 100644 frontend/src/lib/capitalMetrics.ts (limited to 'frontend/src') diff --git a/frontend/src/lib/capitalMetrics.test.ts b/frontend/src/lib/capitalMetrics.test.ts new file mode 100644 index 0000000..b33cca7 --- /dev/null +++ b/frontend/src/lib/capitalMetrics.test.ts @@ -0,0 +1,72 @@ +// 投入口径 reporting 推导函数测试(Leader 第二轮返工后追加任务 A3)。 +// 断言锚点 = 生产现网 run 26e9c9ab-2fad-4a7d-8190-2a7134a11f76 的真实账目: +// 累计投入 12029.09 / 最终持仓估值 10846.00 / 投入回报率 -9.84%。 +// 口径:投入 = 首日 cash − 末日 cash;估值 = Σ 末日 positions × closes; +// 回报 = 估值 / 投入 − 1。不含初始账户闲置现金。 +import { describe, expect, it } from 'vitest'; +import { capitalReporting, fmtInvestmentPct } from './capitalMetrics'; + +// 忠实于现网该 run 的首末两行(中间行与本口径无关)。 +const fixture = { + equity: [ + { date: '2025-09-18', equity: 100000.0, cash: 100000.0, closes: { '159399': 1.127 }, positions: { '159399': 0 } }, + { date: '2026-09-17', equity: 98816.91045543, cash: 87970.91045543001, closes: { '159399': 0.986 }, positions: { '159399': 11000 } } + ] +} as const; + +describe('capitalReporting(现网 run 26e9c9ab 真实账目锚点)', () => { + it('累计投入 = 首日 cash − 末日 cash,保留 2 位', () => { + const c = capitalReporting(fixture as never); + expect(c.invested).toBeCloseTo(12029.09, 2); + }); + + it('最终持仓估值 = Σ 末日 positions × closes(10846.00)', () => { + const c = capitalReporting(fixture as never); + expect(c.holdingValue).toBeCloseTo(10846.0, 2); + }); + + it('投入回报率 = 估值 / 投入 − 1,带正负号与百分号(-9.84%)', () => { + const c = capitalReporting(fixture as never); + expect(c.returnPct).toBe('-9.84%'); + }); + + it('多持仓键:多个 symbol 的市值求和', () => { + const multi = { + equity: [ + { date: 'd0', equity: 50000, cash: 50000 }, + { date: 'd1', equity: 50600, cash: 30000, closes: { A: 10.0, B: 2.0 }, positions: { A: 2000, B: 300 } } + ] + } as never; + const c = capitalReporting(multi); + expect(c.invested).toBeCloseTo(20000, 2); + expect(c.holdingValue).toBeCloseTo(20000 + 600, 2); + expect(c.returnPct).toBe('+3.00%'); + }); + + it('无投入(未建仓)时回报为 null,不除零', () => { + const flat = { equity: [{ date: 'd0', equity: 100000, cash: 100000 }] } as never; + const c = capitalReporting(flat); + expect(c.invested).toBeCloseTo(0, 2); + expect(c.holdingValue).toBeCloseTo(0, 2); + expect(c.returnPct).toBeNull(); + }); + + it('空 equity 返回全 null,UI 可渲染占位', () => { + const c = capitalReporting({ equity: [] } as never); + expect(c.invested).toBeNull(); + expect(c.holdingValue).toBeNull(); + expect(c.returnPct).toBeNull(); + }); +}); + +describe('fmtInvestmentPct(正负号与百分号)', () => { + it('负值带负号', () => { + expect(fmtInvestmentPct(-0.098435)).toBe('-9.84%'); + }); + it('正值带加号', () => { + expect(fmtInvestmentPct(0.03)).toBe('+3.00%'); + }); + it('四舍五入两位', () => { + expect(fmtInvestmentPct(0.045849)).toBe('+4.58%'); + }); +}); diff --git a/frontend/src/lib/capitalMetrics.ts b/frontend/src/lib/capitalMetrics.ts new file mode 100644 index 0000000..cd062a8 --- /dev/null +++ b/frontend/src/lib/capitalMetrics.ts @@ -0,0 +1,46 @@ +// 投入口径 reporting(任务 A):纯前端推导,不触后端与 worker 容器。 +// 口径(在结果页注明"不含初始账户闲置现金"): +// 累计投入 = 首日 cash − 末日 cash(保留 2 位) +// 最终持仓估值 = Σ 末日 positions[k] × closes[k] +// 投入回报率 = 估值 / 投入 − 1(带正负号与百分号) +// 未建仓/无数据返回 null,由 UI 以 "—" 占位,不编造数字。 +import type { BacktestResult } from './types'; + +export interface CapitalReporting { + invested: number | null; + holdingValue: number | null; + returnPct: string | null; +} + +/** round(v, 2) with fixed exponent (avoids FP drift vs Number(v.toFixed(2))) */ +function r2(v: number): number { + return Number(v.toFixed(2)); +} + +export function fmtInvestmentPct(x: number): string { + return `${x >= 0 ? '+' : ''}${(x * 100).toFixed(2)}%`; +} + +export function capitalReporting( + result: Pick | { equity?: BacktestResult['equity'] | undefined } +): CapitalReporting { + const eq = result?.equity ?? []; + const first = eq[0]; + const last = eq[eq.length - 1]; + if (!first || !last || eq.length < 2) return { invested: null, holdingValue: null, returnPct: null }; + + const invested = r2(first.cash - last.cash); + let holdingValue = 0; + const pos = (last as { positions?: Record }).positions; + const cls = (last as { closes?: Record }).closes; + if (pos && cls) { + for (const sym of Object.keys(pos)) { + holdingValue += (pos[sym] ?? 0) * (cls[sym] ?? 0); + } + } + const investedOut = eq.length >= 2 ? invested : null; + const holdingValueOut = r2(holdingValue); + const returnPct = + investedOut !== null && investedOut > 0 ? fmtInvestmentPct(holdingValueOut / investedOut - 1) : null; + return { invested: investedOut, holdingValue: holdingValueOut, returnPct }; +} -- cgit v1.2.3