From d60659df186cac85ec4cd093a1ebd12e22e0c799 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Fri, 1 May 2026 04:07:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(utils):=20=E5=BB=BA=E7=AB=8B=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E8=B4=A2=E5=8A=A1=E5=BC=95=E6=93=8E=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8A=A0=E6=9D=83=E5=9D=87=E4=BB=B7=E4=B8=8E?= =?UTF-8?q?=E7=B4=AF=E8=AE=A1=E7=9B=88=E4=BA=8F=E6=8E=A8=E6=BC=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 9 +++++- src/utils/finance.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/utils/finance.ts diff --git a/Memory.md b/Memory.md index 67f9d6f..1a9b6b3 100644 --- a/Memory.md +++ b/Memory.md @@ -179,4 +179,11 @@ - 在 `app/dashboard/page.tsx` 的 `loadSnapshots` 中引入前端运行时覆盖策略:先调用 `getPortfolioSummary()` 获取实时总览数据,再调用 `getSnapshots()` 获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。 - 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny` 和 `totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。 - 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts` 中 `getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。 -- 前端 Dashboard 页面中两处 `getSnapshots` 调用(初始加载与重构历史后刷新)均已移除 `limit` 参数。 \ No newline at end of file +- 前端 Dashboard 页面中两处 `getSnapshots` 调用(初始加载与重构历史后刷新)均已移除 `limit` 参数。 + +## 建立无副作用的 Utils 财务引擎 (Task 56a) +- 在 `src/utils/` 目录下新建 `finance.ts`,实现纯函数财务计算器,不涉及任何数据库查询和后端 API。 +- 文件顶部绝对禁止出现 `"use server"` 指令,确保为通用的前端/后端都能调用的纯函数。 +- 引入 `big.js` 用于高精度计算,编写并导出 `calculateAssetMetrics` 函数。 +- 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。 +- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。 \ No newline at end of file diff --git a/src/utils/finance.ts b/src/utils/finance.ts new file mode 100644 index 0000000..d6959b9 --- /dev/null +++ b/src/utils/finance.ts @@ -0,0 +1,67 @@ +import Big from 'big.js'; + +// 定义流水参数结构 +export interface TxRecord { + date: string | Date; + txType: 'BUY' | 'SELL' | 'DIVIDEND' | string; + quantity: string | number; + price: string | number; + fee: string | number; + totalValue?: string | number; +} + +export function calculateAssetMetrics(transactions: TxRecord[], currentPrice: string | number) { + let holdings = new Big(0); + let totalInvested = new Big(0); + let totalRealized = new Big(0); + let averageCost = new Big(0); + + const sortedTx = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + for (const tx of sortedTx) { + const qty = new Big(tx.quantity || 0); + const price = new Big(tx.price || 0); + const fee = new Big(tx.fee || 0); + + if (tx.txType === 'BUY') { + const cost = qty.times(price).plus(fee); + totalInvested = totalInvested.plus(cost); + + if (holdings.plus(qty).gt(0)) { + const oldTotalValue = holdings.times(averageCost); + averageCost = oldTotalValue.plus(cost).div(holdings.plus(qty)); + } + holdings = holdings.plus(qty); + + } else if (tx.txType === 'SELL') { + const revenue = qty.times(price).minus(fee); + totalRealized = totalRealized.plus(revenue); + + holdings = holdings.minus(qty); + if (holdings.lte(0)) { + holdings = new Big(0); + averageCost = new Big(0); + } + + } else if (tx.txType === 'DIVIDEND') { + totalRealized = totalRealized.plus(tx.totalValue || 0); + } + } + + const currentMarketValue = holdings.times(new Big(currentPrice)); + + const accumulatedPnl = currentMarketValue.plus(totalRealized).minus(totalInvested); + + const floatingPnl = holdings.gt(0) ? new Big(currentPrice).minus(averageCost).times(holdings) : new Big(0); + + const dilutedCost = holdings.gt(0) ? totalInvested.minus(totalRealized).div(holdings) : new Big(0); + + return { + holdings: holdings.toString(), + averageCost: averageCost.toString(), + dilutedCost: dilutedCost.toString(), + floatingPnl: floatingPnl.toString(), + accumulatedPnl: accumulatedPnl.toString(), + marketValue: currentMarketValue.toString() + }; +}