feat(utils): 建立纯函数财务引擎,支持加权均价与累计盈亏推演
This commit is contained in:
parent
5269d697b7
commit
d60659df18
@ -180,3 +180,10 @@
|
||||
- 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny` 和 `totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。
|
||||
- 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts` 中 `getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。
|
||||
- 前端 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
67
src/utils/finance.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user