fix(api): 重构实时持仓引擎,统一法币成本的历史汇率核算标准

This commit is contained in:
kennethcheng 2026-05-03 02:36:14 +08:00
parent ef412b366a
commit b8666f6dd1
2 changed files with 35 additions and 30 deletions

View File

@ -1,5 +1,15 @@
# Omniledger 架构与开发记忆 (Memory) # 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) ## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50)
- 在 `src/db/schema.ts``exchangeRatesHistory` 表中新增联合唯一约束 `rate_time_unq`,基于 `(from_currency, to_currency, fetch_time)` 三列,防止重复写入,确保幂等性防线。 - 在 `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`。 - 在 `scripts/` 目录下创建 `seed-historical-rates.ts` 播种脚本,支持运行方式:`npx tsx scripts/seed-historical-rates.ts`。

View File

@ -264,17 +264,13 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
if (isBuy) { if (isBuy) {
holding.quantity = holding.quantity.plus(new Big(tx.quantity)); 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; const txFx = new Big(tx.exchangeRate || '1');
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { const fiatCost = new Big(tx.quantity).times(new Big(tx.price)).times(txFx);
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
if (fallbackRate) { holding.totalBuyCostNative = holding.totalBuyCostNative.plus(new Big(tx.quantity).times(new Big(tx.price)));
appliedRate = fallbackRate; holding.totalBuyCostCny = holding.totalBuyCostCny.plus(fiatCost);
}
}
const costCny = costPerUnit.times(new Big(appliedRate || '1'));
holding.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny);
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity)); 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); holding.firstBuyDate = new Date(tx.executedAt);
} }
} else if (isSell) { } else if (isSell) {
// 计算卖出时的平均成本 (Native) // 已实现盈亏 (Native)
const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price));
let avgCostPerUnitNative = new Big('0'); let avgCostPerUnitNative = new Big('0');
if (holding.totalBuyQuantity.gt(0)) { if (holding.totalBuyQuantity.gt(0)) {
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity); 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 costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity));
const realizedPnlNative = sellRevenueNative.minus(costBasisNative); holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
holding.realizedPnlNative = holding.realizedPnlNative.plus(realizedPnlNative);
// 已实现盈亏 (CNY) 保留兼容 // 已实现盈亏 (CNY) — 使用累计法币成本计算平均成本
let appliedRate = tx.exchangeRate; const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1'));
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'));
let avgCostPerUnitCny = new Big('0'); let avgCostPerUnitCny = new Big('0');
if (holding.totalBuyQuantity.gt(0)) { if (holding.totalBuyQuantity.gt(0)) {
avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity); avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
} }
const costBasisCny = avgCostPerUnitCny.times(new Big(tx.quantity)); holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(avgCostPerUnitCny.times(new Big(tx.quantity))));
const realizedPnlCny = sellRevenueCny.minus(costBasisCny);
holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny); // [核心阻断器] 卖出时按法币成本比例扣减,保持汇率一致性
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)); holding.quantity = holding.quantity.minus(new Big(tx.quantity));
// [核心阻断器] 防浮点数灰尘与清仓重置:一旦清仓,强制清零所有持仓成本 // 清仓重置
if (holding.quantity.lte(new Big('1e-8'))) { if (holding.quantity.lte(new Big('1e-8'))) {
holding.quantity = new Big(0); holding.quantity = new Big(0);
holding.totalBuyCostCny = new Big(0); holding.totalBuyCostCny = new Big(0);