diff --git a/Memory.md b/Memory.md index bc77946..979be22 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,15 @@ # Omniledger 架构与开发记忆 (Memory) +## 修复 portfolio.ts 实时核算引擎,将持仓法币成本的计算逻辑对齐为逐笔乘入历史汇率,彻底消灭大盘图表尾节点本金因汇率波动而变异的 Bug (Task 87) +- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,BUY 交易的法币成本计算存在脆弱的 fallback 逻辑——当 `tx.exchangeRate` 缺失时,代码会回退到当前实时汇率字典 (`rateMap`) 而非使用交易发生时的历史汇率,导致跨期持仓的成本基准被当前汇率污染。SELL 交易的成本扣减逻辑与 BUY 侧不一致,使用了不同的平均成本推导路径。 +- **架构红线**:绝不允许在最后一步用外币总成本乘以当前汇率!每笔买入的法币成本 = 数量 × 价格 × 该笔交易历史汇率 (`tx.exchangeRate`),禁止任何形式的全局汇率乘法。 +- **BUY 侧重构**:移除 `rateMap` fallback 逻辑,强制使用 `tx.exchangeRate || '1'` 作为该笔交易的汇率(与 `reconstructPortfolioHistory` 中的正确算法对齐):`fiatCost = qty * price * txFx`,直接累加至 `totalBuyCostCny`。 +- **SELL 侧重构**:统一使用 `totalBuyCostCny / totalBuyQuantity` 作为平均法币成本,按卖出比例 `sellRatio = sellQty / totalBuyQuantity` 等比例扣减 `totalBuyCostCny` 和 `totalBuyCostNative`,保持汇率一致性。 +- **Dashboard 验证**:`app/dashboard/page.tsx` 的 `loadSnapshots()` 直接使用 `summary.totalCostCny` 写入快照,无多余运算;`net-worth-chart.tsx` 的 Tooltip 从 `_raw.totalCostCny` 读取,数据链路纯净。 +- **验收标准**:Dashboard 走势图今天节点的 Tooltip "投入本金" 从错误的 26 万多回落,与后端 API 输出的 `242239` 保持绝对一致。 + +## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50) + ## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50) - 在 `src/db/schema.ts` 的 `exchangeRatesHistory` 表中新增联合唯一约束 `rate_time_unq`,基于 `(from_currency, to_currency, fetch_time)` 三列,防止重复写入,确保幂等性防线。 - 在 `scripts/` 目录下创建 `seed-historical-rates.ts` 播种脚本,支持运行方式:`npx tsx scripts/seed-historical-rates.ts`。 diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index ac58435..a938e2d 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -264,17 +264,13 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr if (isBuy) { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); - const costPerUnit = new Big(tx.quantity).times(new Big(tx.price)); - holding.totalBuyCostNative = holding.totalBuyCostNative.plus(costPerUnit); - let appliedRate = tx.exchangeRate; - if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { - const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY'); - if (fallbackRate) { - appliedRate = fallbackRate; - } - } - const costCny = costPerUnit.times(new Big(appliedRate || '1')); - holding.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny); + + // [架构红线] 买入法币成本 = 数量 * 价格 * 该笔交易历史汇率,禁止在最后乘以当前汇率 + const txFx = new Big(tx.exchangeRate || '1'); + const fiatCost = new Big(tx.quantity).times(new Big(tx.price)).times(txFx); + + holding.totalBuyCostNative = holding.totalBuyCostNative.plus(new Big(tx.quantity).times(new Big(tx.price))); + holding.totalBuyCostCny = holding.totalBuyCostCny.plus(fiatCost); holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity)); // 记录首次买入日期 @@ -282,38 +278,37 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr holding.firstBuyDate = new Date(tx.executedAt); } } else if (isSell) { - // 计算卖出时的平均成本 (Native) + // 已实现盈亏 (Native) + const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price)); let avgCostPerUnitNative = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity); } - - // 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (Native) - const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price)); const costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity)); - const realizedPnlNative = sellRevenueNative.minus(costBasisNative); - holding.realizedPnlNative = holding.realizedPnlNative.plus(realizedPnlNative); + holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative)); - // 已实现盈亏 (CNY) 保留兼容 - let appliedRate = tx.exchangeRate; - if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { - const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY'); - if (fallbackRate) { - appliedRate = fallbackRate; - } - } - const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(appliedRate || '1')); + // 已实现盈亏 (CNY) — 使用累计法币成本计算平均成本 + const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1')); let avgCostPerUnitCny = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity); } - const costBasisCny = avgCostPerUnitCny.times(new Big(tx.quantity)); - const realizedPnlCny = sellRevenueCny.minus(costBasisCny); - holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny); + holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(avgCostPerUnitCny.times(new Big(tx.quantity)))); + + // [核心阻断器] 卖出时按法币成本比例扣减,保持汇率一致性 + if (holding.totalBuyCostCny.gt(0) && holding.totalBuyQuantity.gt(0)) { + const sellRatio = new Big(tx.quantity).div(holding.totalBuyQuantity); + holding.totalBuyCostCny = holding.totalBuyCostCny.minus( + holding.totalBuyCostCny.times(sellRatio) + ); + holding.totalBuyCostNative = holding.totalBuyCostNative.minus( + holding.totalBuyCostNative.times(sellRatio) + ); + } holding.quantity = holding.quantity.minus(new Big(tx.quantity)); - // [核心阻断器] 防浮点数灰尘与清仓重置:一旦清仓,强制清零所有持仓成本 + // 清仓重置 if (holding.quantity.lte(new Big('1e-8'))) { holding.quantity = new Big(0); holding.totalBuyCostCny = new Big(0);