diff --git a/Memory.md b/Memory.md index dbf48ae..cc8c038 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,12 @@ # Omniledger 架构与开发记忆 (Memory) +## 重构 PnL 聚合引擎,增加 tradeDate + createdAt 双重防碰撞排序,引入交易类型强转大写机制,并实装了清仓归零阻断器,彻底解决 T+0 交易残留 0 成本和幽灵持仓数量的致命 Bug (Task 76) +- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中,将交易流水排序从单一 `executedAt` 升级为三重排序:`asc(executedAt) + asc(createdAt) + asc(id)`,彻底杜绝同一分钟内的 T+0 交易因时间戳碰撞导致的聚合乱序。 +- **强制交易类型标准化**:在遍历循环的第一行注入 `String(tx.txType).toUpperCase().trim()` 处理,并兼容中文脏数据(`买入`/`卖出`),修复因大小写不一致或空格导致的类型匹配静默失效。 +- **清仓归零阻断器 (Zero-Position Circuit Breaker)**:在 SELL 交易扣减数量后,增加 `holding.quantity.lte(new Big('1e-8'))` 检测,一旦清仓(含浮点灰尘),立即强制清零 `totalBuyCostCny`、`totalBuyCostNative`、`totalBuyQuantity`,但**保留 `realizedPnlCny` 和 `realizedPnlNative`**(已实现盈亏),确保低买高卖赚的钱不丢失。 +- **验收标准**:清仓资产(如"沪上阿姨 02859")的持仓量归零、成本价清零不再出现负数或乱码、累计盈亏正确保留。 +- CSV 导出和大盘概览自动受益于底层聚合修复,无需额外修改。 + ## 废弃 JS Date 对象隐式比较,采用 SQL 字符串绝对边界 (YYYY-MM-DD 23:59:59) 重构汇率查询逻辑,彻底解决时区偏移导致的真实汇率读取失败问题 (Task 75) - 在 `src/actions/snapshots.ts` 的 `buildDailyRatesMap` 函数中,**彻底废弃**基于 `new Date(targetDateStr + 'T23:59:59.999')` 的 JS Date 对象比较逻辑。 - **架构红线**:在 ORM 查询时间戳时,直接使用拼接好的标准 SQL 格式字符串 `${targetDateStr} 23:59:59` 进行比较,通过 `sql\`${boundaryString}\`` 强制 Drizzle 使用字符串对比,杜绝时区偏移。 diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 6dcb8b8..1489176 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -194,7 +194,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr }) .from(transactions) .leftJoin(assets, eq(assets.id, transactions.assetId)) - .orderBy(asc(transactions.executedAt)); + .orderBy(asc(transactions.executedAt), asc(transactions.createdAt), asc(transactions.id)); const dynamicRateMap = await getLatestRatesMap(); @@ -232,6 +232,11 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr for (const tx of allTransactions) { if (!tx.assetId) continue; + // [架构红线] 强制标准化交易类型:大写 + 去空格,兼容中文脏数据 + const txType = String(tx.txType).toUpperCase().trim(); + const isBuy = txType === 'BUY' || txType === '\u5165\u4e70'; + const isSell = txType === 'SELL' || txType === '\u5356\u51fa'; + const existing = holdings.get(tx.assetId); if (!existing) { holdings.set(tx.assetId, { @@ -257,7 +262,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr const holding = holdings.get(tx.assetId)!; - if (tx.txType === 'BUY') { + if (isBuy) { 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); @@ -276,7 +281,7 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr if (!holding.firstBuyDate && tx.executedAt) { holding.firstBuyDate = new Date(tx.executedAt); } - } else if (tx.txType === 'SELL') { + } else if (isSell) { // 计算卖出时的平均成本 (Native) let avgCostPerUnitNative = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { @@ -307,9 +312,17 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny); holding.quantity = holding.quantity.minus(new Big(tx.quantity)); - } else if (tx.txType === 'AIRDROP') { + + // [核心阻断器] 防浮点数灰尘与清仓重置:一旦清仓,强制清零所有持仓成本 + if (holding.quantity.lte(new Big('1e-8'))) { + holding.quantity = new Big(0); + holding.totalBuyCostCny = new Big(0); + holding.totalBuyCostNative = new Big(0); + holding.totalBuyQuantity = new Big(0); + } + } else if (txType === 'AIRDROP') { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); - } else if (tx.txType === 'DIVIDEND') { + } else if (txType === 'DIVIDEND') { const dividendAmountNative = new Big(tx.quantity).times(new Big(tx.price)); const dividendCny = dividendAmountNative.times(new Big(tx.exchangeRate || '1')); holding.accumulatedDividendsCny = holding.accumulatedDividendsCny.plus(dividendCny);