fix(api): 引入成本计价基准量隔离分母污染,彻底对齐历史本金

This commit is contained in:
2026-05-03 03:28:04 +08:00
parent ab8b49ca23
commit 3ea8d5c550
2 changed files with 34 additions and 18 deletions
+24 -17
View File
@@ -211,6 +211,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
name: string | null;
type: string;
quantity: Big;
costBasisQuantity: Big;
baseCurrency: string;
latestPrice: string;
exchange: string;
@@ -234,8 +235,8 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
// [架构红线] 强制标准化交易类型:大写 + 去空格,兼容中文脏数据
const txType = String(tx.txType).toUpperCase().trim();
const isBuy = txType === 'BUY' || txType === '\u5165\u4e70';
const isSell = txType === 'SELL' || txType === '\u5356\u51fa';
const isBuy = txType === 'BUY' || txType === '买入' || txType === '入金';
const isSell = txType === 'SELL' || txType === '卖出' || txType === '出金';
const existing = holdings.get(tx.assetId);
if (!existing) {
@@ -245,6 +246,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
name: tx.assetName,
type: tx.assetType || 'CASH',
quantity: new Big('0'),
costBasisQuantity: new Big('0'),
baseCurrency: tx.assetBaseCurrency || '',
latestPrice: tx.assetLatestPrice || '0',
exchange: tx.assetExchange || 'US',
@@ -263,15 +265,17 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
const holding = holdings.get(tx.assetId)!;
if (isBuy) {
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
const qty = new Big(tx.quantity);
holding.quantity = holding.quantity.plus(qty);
holding.costBasisQuantity = holding.costBasisQuantity.plus(qty);
// [架构红线] 买入法币成本 = 数量 * 价格 * 该笔交易历史汇率,禁止在最后乘以当前汇率
const txFx = new Big(tx.exchangeRate || '1');
const fiatCost = new Big(tx.quantity).times(new Big(tx.price)).times(txFx);
const fiatCost = qty.times(new Big(tx.price)).times(txFx);
holding.totalBuyCostNative = holding.totalBuyCostNative.plus(new Big(tx.quantity).times(new Big(tx.price)));
holding.totalBuyCostNative = holding.totalBuyCostNative.plus(qty.times(new Big(tx.price)));
holding.totalBuyCostCny = holding.totalBuyCostCny.plus(fiatCost);
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity));
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(qty);
// 记录首次买入日期
if (!holding.firstBuyDate && tx.executedAt) {
@@ -282,38 +286,41 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
const sellPrice = new Big(tx.price);
const txFx = new Big(tx.exchangeRate || '1');
// 1. Native 维度的平均成本与已实现盈亏计算
// 1. Native 维度 (使用纯净的 costBasisQuantity 作为分母)
let avgCostNative = new Big('0');
if (holding.quantity.gt(0)) {
avgCostNative = holding.totalBuyCostNative.div(holding.quantity); // 核心修复:使用当前真实持仓量
if (holding.costBasisQuantity.gt(0)) {
avgCostNative = holding.totalBuyCostNative.div(holding.costBasisQuantity);
}
const costBasisNative = avgCostNative.times(sellQty);
const sellRevenueNative = sellQty.times(sellPrice);
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
// 2. CNY (法币) 维度的平均成本与已实现盈亏计算
// 2. CNY (法币) 维度 (使用纯净的 costBasisQuantity 作为分母)
let avgCostCny = new Big('0');
if (holding.quantity.gt(0)) {
avgCostCny = holding.totalBuyCostCny.div(holding.quantity); // 核心修复:使用当前真实持仓量
if (holding.costBasisQuantity.gt(0)) {
avgCostCny = holding.totalBuyCostCny.div(holding.costBasisQuantity);
}
const costBasisCny = avgCostCny.times(sellQty);
const sellRevenueCny = sellRevenueNative.times(txFx);
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(costBasisCny));
// 3. 扣减本金余额 (确保法币与外币同步等比下降)
// 3. 扣减本金与持仓
holding.totalBuyCostNative = holding.totalBuyCostNative.minus(costBasisNative);
holding.totalBuyCostCny = holding.totalBuyCostCny.minus(costBasisCny);
// 4. 更新真实持仓数量
holding.quantity = holding.quantity.minus(sellQty);
holding.costBasisQuantity = holding.costBasisQuantity.minus(sellQty);
// 清仓重置兜底逻辑 (防御浮点数精度残留)
if (holding.quantity.lte(new Big('1e-8'))) {
holding.quantity = new Big(0);
// 4. 清仓重置兜底逻辑 (防御浮点数精度残留)
if (holding.costBasisQuantity.lte(new Big('1e-8'))) {
holding.costBasisQuantity = new Big(0);
holding.totalBuyCostCny = new Big(0);
holding.totalBuyCostNative = new Big(0);
holding.totalBuyQuantity = new Big(0);
}
if (holding.quantity.lte(new Big('1e-8'))) {
holding.quantity = new Big(0);
}
} else if (txType === 'AIRDROP') {
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
} else if (txType === 'DIVIDEND') {