fix(api): 重构实时持仓引擎,统一法币成本的历史汇率核算标准
This commit is contained in:
parent
ef412b366a
commit
b8666f6dd1
10
Memory.md
10
Memory.md
@ -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`。
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user