summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/src/lib/capitalMetrics.test.ts72
-rw-r--r--frontend/src/lib/capitalMetrics.ts46
2 files changed, 118 insertions, 0 deletions
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<BacktestResult, 'equity'> | { 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<string, number> }).positions;
+ const cls = (last as { closes?: Record<string, number> }).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 };
+}