fix(api): 修复卖出核算的分母逻辑,彻底对齐持仓与快照本金
This commit is contained in:
parent
b8666f6dd1
commit
ab8b49ca23
@ -1,5 +1,12 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# Omniledger 架构与开发记忆 (Memory)
|
||||||
|
|
||||||
|
## 修复 portfolio.ts 卖出交易时的平均成本分母 Bug,将 totalBuyQuantity 替换为真实的当前 quantity,彻底消除了频繁交易导致的本金虚高幽灵账目 (Task 88)
|
||||||
|
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,SELL 交易的平均成本计算使用了 `holding.totalBuyQuantity`(历史累计买入总量)作为分母,而非 `holding.quantity`(卖出前的实际真实持仓量)。当同一资产存在多次"买-卖-买-卖"循环时,`totalBuyQuantity` 会不断累加而不再下降,导致分母远大于真实持仓量,平均成本被严重稀释,卖出时扣减的 `totalBuyCostNative/Cny` 不足,最终造成 Dashboard 投入本金虚高(幽灵本金)。
|
||||||
|
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.totalBuyQuantity` 作为分母!必须使用发生交易前的实际持仓量 `holding.quantity`。
|
||||||
|
- **SELL 侧重构**:完全重写 `else if (isSell)` 代码块,Native 维度使用 `holding.totalBuyCostNative.div(holding.quantity)` 计算平均成本,CNY 维度使用 `holding.totalBuyCostCny.div(holding.quantity)` 计算平均成本,按卖出数量精确扣减成本本金,确保法币与外币同步等比下降。
|
||||||
|
- **清仓重置兜底**:保留 `1e-8` 精度容差的清仓归零逻辑,防御浮点数精度残留。
|
||||||
|
- **验收标准**:Dashboard 走势图今天节点(5月3日)的"投入本金"从错误的 267k 跌回 242k 左右,与时光机(JSON)导出的历史底盘彻底咬合。
|
||||||
|
|
||||||
## 修复 portfolio.ts 实时核算引擎,将持仓法币成本的计算逻辑对齐为逐笔乘入历史汇率,彻底消灭大盘图表尾节点本金因汇率波动而变异的 Bug (Task 87)
|
## 修复 portfolio.ts 实时核算引擎,将持仓法币成本的计算逻辑对齐为逐笔乘入历史汇率,彻底消灭大盘图表尾节点本金因汇率波动而变异的 Bug (Task 87)
|
||||||
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,BUY 交易的法币成本计算存在脆弱的 fallback 逻辑——当 `tx.exchangeRate` 缺失时,代码会回退到当前实时汇率字典 (`rateMap`) 而非使用交易发生时的历史汇率,导致跨期持仓的成本基准被当前汇率污染。SELL 交易的成本扣减逻辑与 BUY 侧不一致,使用了不同的平均成本推导路径。
|
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,BUY 交易的法币成本计算存在脆弱的 fallback 逻辑——当 `tx.exchangeRate` 缺失时,代码会回退到当前实时汇率字典 (`rateMap`) 而非使用交易发生时的历史汇率,导致跨期持仓的成本基准被当前汇率污染。SELL 交易的成本扣减逻辑与 BUY 侧不一致,使用了不同的平均成本推导路径。
|
||||||
- **架构红线**:绝不允许在最后一步用外币总成本乘以当前汇率!每笔买入的法币成本 = 数量 × 价格 × 该笔交易历史汇率 (`tx.exchangeRate`),禁止任何形式的全局汇率乘法。
|
- **架构红线**:绝不允许在最后一步用外币总成本乘以当前汇率!每笔买入的法币成本 = 数量 × 价格 × 该笔交易历史汇率 (`tx.exchangeRate`),禁止任何形式的全局汇率乘法。
|
||||||
|
|||||||
@ -278,37 +278,36 @@ 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)
|
const sellQty = new Big(tx.quantity);
|
||||||
const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price));
|
const sellPrice = new Big(tx.price);
|
||||||
let avgCostPerUnitNative = new Big('0');
|
const txFx = new Big(tx.exchangeRate || '1');
|
||||||
if (holding.totalBuyQuantity.gt(0)) {
|
|
||||||
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
|
// 1. Native 维度的平均成本与已实现盈亏计算
|
||||||
|
let avgCostNative = new Big('0');
|
||||||
|
if (holding.quantity.gt(0)) {
|
||||||
|
avgCostNative = holding.totalBuyCostNative.div(holding.quantity); // 核心修复:使用当前真实持仓量
|
||||||
}
|
}
|
||||||
const costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity));
|
const costBasisNative = avgCostNative.times(sellQty);
|
||||||
|
const sellRevenueNative = sellQty.times(sellPrice);
|
||||||
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
|
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
|
||||||
|
|
||||||
// 已实现盈亏 (CNY) — 使用累计法币成本计算平均成本
|
// 2. CNY (法币) 维度的平均成本与已实现盈亏计算
|
||||||
const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1'));
|
let avgCostCny = new Big('0');
|
||||||
let avgCostPerUnitCny = new Big('0');
|
if (holding.quantity.gt(0)) {
|
||||||
if (holding.totalBuyQuantity.gt(0)) {
|
avgCostCny = holding.totalBuyCostCny.div(holding.quantity); // 核心修复:使用当前真实持仓量
|
||||||
avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
|
||||||
}
|
}
|
||||||
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(avgCostPerUnitCny.times(new Big(tx.quantity))));
|
const costBasisCny = avgCostCny.times(sellQty);
|
||||||
|
const sellRevenueCny = sellRevenueNative.times(txFx);
|
||||||
|
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(costBasisCny));
|
||||||
|
|
||||||
// [核心阻断器] 卖出时按法币成本比例扣减,保持汇率一致性
|
// 3. 扣减本金余额 (确保法币与外币同步等比下降)
|
||||||
if (holding.totalBuyCostCny.gt(0) && holding.totalBuyQuantity.gt(0)) {
|
holding.totalBuyCostNative = holding.totalBuyCostNative.minus(costBasisNative);
|
||||||
const sellRatio = new Big(tx.quantity).div(holding.totalBuyQuantity);
|
holding.totalBuyCostCny = holding.totalBuyCostCny.minus(costBasisCny);
|
||||||
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));
|
// 4. 更新真实持仓数量
|
||||||
|
holding.quantity = holding.quantity.minus(sellQty);
|
||||||
|
|
||||||
// 清仓重置
|
// 清仓重置兜底逻辑 (防御浮点数精度残留)
|
||||||
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