feat(utils): 建立纯函数财务引擎,支持加权均价与累计盈亏推演

This commit is contained in:
kennethcheng 2026-05-01 04:07:04 +08:00
parent 5269d697b7
commit d60659df18
2 changed files with 75 additions and 1 deletions

View File

@ -179,4 +179,11 @@
- 在 `app/dashboard/page.tsx``loadSnapshots` 中引入前端运行时覆盖策略:先调用 `getPortfolioSummary()` 获取实时总览数据,再调用 `getSnapshots()` 获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。 - 在 `app/dashboard/page.tsx``loadSnapshots` 中引入前端运行时覆盖策略:先调用 `getPortfolioSummary()` 获取实时总览数据,再调用 `getSnapshots()` 获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。
- 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny``totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。 - 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny``totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。
- 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts``getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。 - 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts``getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。
- 前端 Dashboard 页面中两处 `getSnapshots` 调用(初始加载与重构历史后刷新)均已移除 `limit` 参数。 - 前端 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`(市值)。

67
src/utils/finance.ts Normal file
View File

@ -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()
};
}