fix(api): 引入成本计价基准量隔离分母污染,彻底对齐历史本金
This commit is contained in:
parent
ab8b49ca23
commit
3ea8d5c550
@ -460,3 +460,12 @@
|
|||||||
- **架构红线**:`app/api/admin/rebuild-snapshots/route.ts` 中的生产级 POST + Bearer Token 强校验代码未被修改,保持原有的安全隔离设计。
|
- **架构红线**:`app/api/admin/rebuild-snapshots/route.ts` 中的生产级 POST + Bearer Token 强校验代码未被修改,保持原有的安全隔离设计。
|
||||||
- **运行方式**:`npx tsx scripts/trigger-rebuild.ts`(需确保 `npm run dev` 在另一个终端运行且端口为 8080)。
|
- **运行方式**:`npx tsx scripts/trigger-rebuild.ts`(需确保 `npm run dev` 在另一个终端运行且端口为 8080)。
|
||||||
- **设计收益**:本地开发者无需记忆 curl 命令或手动构造请求头,通过脚本即可安全触发历史快照重建,降低了运维门槛并保持了与生产鉴权机制的一致性。
|
- **设计收益**:本地开发者无需记忆 curl 命令或手动构造请求头,通过脚本即可安全触发历史快照重建,降低了运维门槛并保持了与生产鉴权机制的一致性。
|
||||||
|
|
||||||
|
## 修复 portfolio.ts 卖出核算机制,引入 costBasisQuantity 隔离空投等非交易流水对平均成本分母的污染,解决中文字符解析 bug,实现实时引擎与时光机引擎投入本金的 100% 数学对齐 (Task 89)
|
||||||
|
- **根因分析**:在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,SELL 交易的平均成本计算使用 `holding.quantity`(含空投、分红扩仓的真实持仓量)作为分母,当资产存在 AIRDROP 等零成本扩仓流水时,`holding.quantity` 被无成本污染,导致平均成本 `totalBuyCost / holding.quantity` 被严重稀释。卖出时扣减的 `totalBuyCostNative/Cny` 不足,造成 Dashboard 投入本金虚高(如 267k vs 真实 242k)。
|
||||||
|
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.quantity`(含非交易扩仓)作为分母!必须使用纯净的、仅由 BUY 交易驱动的成本计价基准量 `costBasisQuantity`。
|
||||||
|
- **双轨隔离设计**:在 `holdings.set` 初始化结构中新增 `costBasisQuantity` 字段。BUY 时同步增加 `quantity` 和 `costBasisQuantity`;AIRDROP 仅增加 `quantity`,绝不触碰 `costBasisQuantity`,完美隔绝污染。
|
||||||
|
- **SELL 侧双轨计算**:Native 维度使用 `holding.totalBuyCostNative / holding.costBasisQuantity` 计算平均成本,CNY 维度使用 `holding.totalBuyCostCny / holding.costBasisQuantity` 计算平均成本,按卖出数量精确扣减成本本金与 `costBasisQuantity`,确保法币与外币同步等比下降。
|
||||||
|
- **清仓重置兜底**:`costBasisQuantity` 和 `quantity` 双独立归零逻辑,`1e-8` 精度容差下分别清零 `totalBuyCostCny`/`totalBuyCostNative`/`totalBuyQuantity` 与 `quantity`,防御浮点数精度残留。
|
||||||
|
- **中文字符解析修复**:将 `txType` 比较从 Unicode 转义序列 `\u5165\u4e70`/`\u5356\u51fa` 替换为明文中文 `'买入'`/`'卖出'`,并增加 `'入金'`/`'出金'` 别名,兼容合规的 CSV 导入脏数据。
|
||||||
|
- **验收标准**:Dashboard 走势图今天节点(5月3日)的"投入本金"从虚假的 267k 回归到与 JSON 完全相同的 242,239,两者达到 100% 完美的数学对齐。
|
||||||
@ -211,6 +211,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
type: string;
|
type: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
|
costBasisQuantity: Big;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
latestPrice: string;
|
latestPrice: string;
|
||||||
exchange: string;
|
exchange: string;
|
||||||
@ -234,8 +235,8 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
|
|
||||||
// [架构红线] 强制标准化交易类型:大写 + 去空格,兼容中文脏数据
|
// [架构红线] 强制标准化交易类型:大写 + 去空格,兼容中文脏数据
|
||||||
const txType = String(tx.txType).toUpperCase().trim();
|
const txType = String(tx.txType).toUpperCase().trim();
|
||||||
const isBuy = txType === 'BUY' || txType === '\u5165\u4e70';
|
const isBuy = txType === 'BUY' || txType === '买入' || txType === '入金';
|
||||||
const isSell = txType === 'SELL' || txType === '\u5356\u51fa';
|
const isSell = txType === 'SELL' || txType === '卖出' || txType === '出金';
|
||||||
|
|
||||||
const existing = holdings.get(tx.assetId);
|
const existing = holdings.get(tx.assetId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@ -245,6 +246,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
name: tx.assetName,
|
name: tx.assetName,
|
||||||
type: tx.assetType || 'CASH',
|
type: tx.assetType || 'CASH',
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
|
costBasisQuantity: new Big('0'),
|
||||||
baseCurrency: tx.assetBaseCurrency || '',
|
baseCurrency: tx.assetBaseCurrency || '',
|
||||||
latestPrice: tx.assetLatestPrice || '0',
|
latestPrice: tx.assetLatestPrice || '0',
|
||||||
exchange: tx.assetExchange || 'US',
|
exchange: tx.assetExchange || 'US',
|
||||||
@ -263,15 +265,17 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
const holding = holdings.get(tx.assetId)!;
|
const holding = holdings.get(tx.assetId)!;
|
||||||
|
|
||||||
if (isBuy) {
|
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 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.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) {
|
if (!holding.firstBuyDate && tx.executedAt) {
|
||||||
@ -282,38 +286,41 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
const sellPrice = new Big(tx.price);
|
const sellPrice = new Big(tx.price);
|
||||||
const txFx = new Big(tx.exchangeRate || '1');
|
const txFx = new Big(tx.exchangeRate || '1');
|
||||||
|
|
||||||
// 1. Native 维度的平均成本与已实现盈亏计算
|
// 1. Native 维度 (使用纯净的 costBasisQuantity 作为分母)
|
||||||
let avgCostNative = new Big('0');
|
let avgCostNative = new Big('0');
|
||||||
if (holding.quantity.gt(0)) {
|
if (holding.costBasisQuantity.gt(0)) {
|
||||||
avgCostNative = holding.totalBuyCostNative.div(holding.quantity); // 核心修复:使用当前真实持仓量
|
avgCostNative = holding.totalBuyCostNative.div(holding.costBasisQuantity);
|
||||||
}
|
}
|
||||||
const costBasisNative = avgCostNative.times(sellQty);
|
const costBasisNative = avgCostNative.times(sellQty);
|
||||||
const sellRevenueNative = sellQty.times(sellPrice);
|
const sellRevenueNative = sellQty.times(sellPrice);
|
||||||
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
|
holding.realizedPnlNative = holding.realizedPnlNative.plus(sellRevenueNative.minus(costBasisNative));
|
||||||
|
|
||||||
// 2. CNY (法币) 维度的平均成本与已实现盈亏计算
|
// 2. CNY (法币) 维度 (使用纯净的 costBasisQuantity 作为分母)
|
||||||
let avgCostCny = new Big('0');
|
let avgCostCny = new Big('0');
|
||||||
if (holding.quantity.gt(0)) {
|
if (holding.costBasisQuantity.gt(0)) {
|
||||||
avgCostCny = holding.totalBuyCostCny.div(holding.quantity); // 核心修复:使用当前真实持仓量
|
avgCostCny = holding.totalBuyCostCny.div(holding.costBasisQuantity);
|
||||||
}
|
}
|
||||||
const costBasisCny = avgCostCny.times(sellQty);
|
const costBasisCny = avgCostCny.times(sellQty);
|
||||||
const sellRevenueCny = sellRevenueNative.times(txFx);
|
const sellRevenueCny = sellRevenueNative.times(txFx);
|
||||||
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(costBasisCny));
|
holding.realizedPnlCny = holding.realizedPnlCny.plus(sellRevenueCny.minus(costBasisCny));
|
||||||
|
|
||||||
// 3. 扣减本金余额 (确保法币与外币同步等比下降)
|
// 3. 扣减本金与持仓
|
||||||
holding.totalBuyCostNative = holding.totalBuyCostNative.minus(costBasisNative);
|
holding.totalBuyCostNative = holding.totalBuyCostNative.minus(costBasisNative);
|
||||||
holding.totalBuyCostCny = holding.totalBuyCostCny.minus(costBasisCny);
|
holding.totalBuyCostCny = holding.totalBuyCostCny.minus(costBasisCny);
|
||||||
|
|
||||||
// 4. 更新真实持仓数量
|
|
||||||
holding.quantity = holding.quantity.minus(sellQty);
|
holding.quantity = holding.quantity.minus(sellQty);
|
||||||
|
holding.costBasisQuantity = holding.costBasisQuantity.minus(sellQty);
|
||||||
|
|
||||||
// 清仓重置兜底逻辑 (防御浮点数精度残留)
|
// 4. 清仓重置兜底逻辑 (防御浮点数精度残留)
|
||||||
if (holding.quantity.lte(new Big('1e-8'))) {
|
if (holding.costBasisQuantity.lte(new Big('1e-8'))) {
|
||||||
holding.quantity = new Big(0);
|
holding.costBasisQuantity = new Big(0);
|
||||||
holding.totalBuyCostCny = new Big(0);
|
holding.totalBuyCostCny = new Big(0);
|
||||||
holding.totalBuyCostNative = new Big(0);
|
holding.totalBuyCostNative = new Big(0);
|
||||||
holding.totalBuyQuantity = 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') {
|
} else if (txType === 'AIRDROP') {
|
||||||
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
||||||
} else if (txType === 'DIVIDEND') {
|
} else if (txType === 'DIVIDEND') {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user