diff --git a/Memory.md b/Memory.md index 1a9b6b3..7e7366a 100644 --- a/Memory.md +++ b/Memory.md @@ -186,4 +186,10 @@ - 文件顶部绝对禁止出现 `"use server"` 指令,确保为通用的前端/后端都能调用的纯函数。 - 引入 `big.js` 用于高精度计算,编写并导出 `calculateAssetMetrics` 函数。 - 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。 -- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。 \ No newline at end of file +- 输出六大核心财务指标:`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)数据精确。 \ No newline at end of file diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 722ce91..2966006 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -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 { // 总盈亏 (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 { 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(),