feat(ledger): 接入计算引擎,实现 Dashboard 指标的精确数据对齐

This commit is contained in:
kennethcheng 2026-05-01 04:17:01 +08:00
parent d60659df18
commit 9ce398efb1
2 changed files with 26 additions and 38 deletions

View File

@ -186,4 +186,10 @@
- 文件顶部绝对禁止出现 `"use server"` 指令,确保为通用的前端/后端都能调用的纯函数。
- 引入 `big.js` 用于高精度计算,编写并导出 `calculateAssetMetrics` 函数。
- 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。
- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。
- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。
## 打通 Dashboard 与 finance utils 的数据链路 (Task 56b)
- 在 `src/actions/portfolio.ts` 顶部引入 `calculateAssetMetrics` 工具函数,实现财务引擎接入。
- 重构 `getPortfolioPositions` 的第二个循环:对每个资产调用 `calculateAssetMetrics(transactions, latestPrice)`,将返回的 `holdings`、`averageCost`、`dilutedCost`、`floatingPnl`、`accumulatedPnl`、`marketValue` 映射到 Position 对象的 Native 币种字段。
- Dashboard 表格字段精确对齐:现價→`latestPrice`、市值→`metrics.marketValue`、攤薄/成本→`metrics.dilutedCost / metrics.averageCost`、浮動盈虧→`metrics.floatingPnl`、累計盈虧→`metrics.accumulatedPnl`。
- 累计盈亏验证公式:`accumulatedPnl = marketValue + 卖出/分红现金 - 总投入`确保有卖出或分红记录的资产如英特尔、分红ETF数据精确。

View File

@ -4,6 +4,7 @@ import { db } from '@/db';
import { transactions, assets, exchangeRates } from '@/db/schema';
import Big from 'big.js';
import { asc, eq } from 'drizzle-orm';
import { calculateAssetMetrics } from '@/utils/finance';
interface Position {
assetId: string;
@ -312,54 +313,35 @@ export async function getPortfolioPositions(): Promise<Position[]> {
// 总盈亏 (CNY)
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
// Native 原生币种计算
const marketValueNative = new Big(holding.latestPrice).times(holding.quantity);
const currentNativeValue = marketValueNative;
const metrics = calculateAssetMetrics(
holding.transactions.map(tx => ({
date: tx.executedAt ?? new Date(),
txType: tx.txType,
quantity: tx.quantity,
price: tx.price,
fee: tx.fee,
})),
holding.latestPrice
);
// 平均成本 (Native) = 总买入成本 (Native) / 总买入数量
let avgCostNative = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCostNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
}
const holdingNative = new Big(metrics.holdings);
const avgCostNative = new Big(metrics.averageCost);
const dilutedCostNative = new Big(metrics.dilutedCost);
const floatingPnlNative = new Big(metrics.floatingPnl);
const cumulativePnlNative = new Big(metrics.accumulatedPnl);
const marketValueNative = new Big(metrics.marketValue);
// 摊薄成本 (Native) = (总买入成本 - 已实现盈亏 - 累计分红) / 当前持仓数量
let dilutedCostNative = new Big('0');
if (holding.quantity.gt(0)) {
dilutedCostNative = holding.totalBuyCostNative.minus(holding.realizedPnlNative).minus(holding.accumulatedDividendsNative).div(holding.quantity);
}
// 浮动盈亏 (Native) = 市值 - (平均成本 * 当前持仓数量)
const floatingPnlNative = marketValueNative.minus(avgCostNative.times(holding.quantity));
// 浮动盈亏百分比 (Native)
let floatingPnlPercent = new Big('0');
const avgCostBasisNative = avgCostNative.times(holding.quantity);
const avgCostBasisNative = avgCostNative.times(holdingNative);
if (avgCostBasisNative.gt(0)) {
floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100'));
}
// 累计盈亏 (Native) = 浮动盈亏 + 已实现盈亏 + 累计分红
const cumulativePnlNative = floatingPnlNative.plus(holding.realizedPnlNative).plus(holding.accumulatedDividendsNative);
// 累计盈亏百分比 (Native) = 累计盈亏 / 总买入成本 * 100
let cumulativePnlPercent = new Big('0');
if (holding.totalBuyCostNative.gt(0)) {
cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100'));
}
// 平均成本 (CNY) 保留兼容
let avgCost = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
}
// 摊薄成本 (CNY) 保留兼容
let dilutedCost = new Big('0');
if (holding.quantity.gt(0)) {
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).div(holding.quantity);
}
// 持仓天数
let holdingDays = 0;
if (holding.firstBuyDate) {
const diffMs = today.getTime() - holding.firstBuyDate.getTime();
@ -374,7 +356,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
symbol: holding.symbol,
name: holding.name,
type: holding.type,
quantity: holding.quantity.toString(),
quantity: holdingNative.toString(),
baseCurrency: holding.baseCurrency,
cnyValue: cnyValue.toString(),
totalCostCny: holding.totalBuyCostCny.toString(),