From b4f21e7cd650a12d2c580283e9c4ad7971ee9996 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Fri, 1 May 2026 05:52:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(ledger):=20=E4=BF=AE=E5=A4=8D=E6=B1=87?= =?UTF-8?q?=E7=8E=87=E8=BD=AC=E6=8D=A2=E7=9A=84=E5=8F=8C=E6=A0=87=E5=B9=BB?= =?UTF-8?q?=E8=A7=89=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=A4=A7=E7=9B=98=E4=B8=8E?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E7=9A=84=E8=B4=A2=E5=8A=A1=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 7 +++++++ scripts/reconstruct.ts | 13 +++++++++++++ src/actions/portfolio.ts | 34 +++++++++++++--------------------- src/actions/snapshots.ts | 25 +++++++++++++------------ 4 files changed, 46 insertions(+), 33 deletions(-) create mode 100644 scripts/reconstruct.ts diff --git a/Memory.md b/Memory.md index e8041c0..8584e03 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,12 @@ # Omniledger 架构与开发记忆 (Memory) +## 修复 calculateAssetMetrics 结果的汇率双重标准解析错误,重构 Live Overview 聚合基准 (Task 59) +- **核心认知纠正:** `calculateAssetMetrics` 引擎产出的所有数据(`marketValue`, `accumulatedPnl`, `floatingPnl`, `dilutedCost` 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。 +- 修复 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()`:之前错误地将 `cnyPrice`(已折算的人民币价格)传入引擎,却只对 `totalInvested` 乘汇率、对 `marketValue` 不乘,形成"双标幻觉"。现改为传入原始币种价格 `priceStr`,然后无例外地将引擎输出的所有金额字段统一乘以 `fxRate` 得到 CNY,确保 `posValueCny` 和 `posCostCny` 使用同一套汇率折算基准。 +- 修复 `src/actions/portfolio.ts` 的 `getPortfolioSummary()`:废弃旧的 `cnyValue`/`pnlCny` 求和逻辑(旧逻辑基于 `calculateCnyValueFromPrice` 双路径计算,与引擎输出存在语义差异),改为从 `getPortfolioPositions()` 返回的 `marketValueCny`/`accumulatedPnlCny`/`floatingPnlCny` 字段在内存中累加,确保大盘汇总与底层明细使用同一套计算源(单一事实来源)。 +- 同步修复 `recordDailySnapshot()`:`totalValueCny` 改为累加 `marketValueCny`,`totalCostCny` 改为 `marketValueCny - accumulatedPnlCny` 推导,与 `getPortfolioSummary` 保持一致。 +- 彻底覆盖历史快照:重新执行 `reconstructPortfolioHistory()`,成功重构 1247 天历史快照数据,消除之前因混用汇率导致的错乱快照。 + ## 全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58) - 修复了跨币种资产直接相加导致的盈亏总额失真问题:USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算,HKD 亏损同理。 - 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 中,对每个资产获取 `exchangeRate`(Base Currency → CNY),将财务引擎 (`calculateAssetMetrics`) 产出的所有绝对金额字段(`marketValue`、`floatingPnl`、`accumulatedPnl`、`dilutedCost`)乘以汇率,映射为 `Cny` 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。 diff --git a/scripts/reconstruct.ts b/scripts/reconstruct.ts new file mode 100644 index 0000000..9defb01 --- /dev/null +++ b/scripts/reconstruct.ts @@ -0,0 +1,13 @@ +import { reconstructPortfolioHistory } from '@/actions/snapshots'; + +async function main() { + console.log('Starting reconstructPortfolioHistory...'); + const result = await reconstructPortfolioHistory(); + console.log('Result:', JSON.stringify(result, null, 2)); + console.log('Done.'); +} + +main().catch(err => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 21e09ed..b320d7c 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -416,28 +416,20 @@ export async function getPortfolioPositions(): Promise { export async function getPortfolioSummary() { const positions = await getPortfolioPositions(); - const totalCnyValue = positions.reduce( - (sum, pos) => sum.plus(new Big(pos.cnyValue)), - new Big('0') - ); + // 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果 + let totalCnyValue = new Big('0'); + let totalPnlCny = new Big('0'); + let totalFloatingPnlCny = new Big('0'); - const totalPnlCny = positions.reduce( - (sum, pos) => sum.plus(new Big(pos.pnlCny)), - new Big('0') - ); - - const unrealizedPnlCny = positions.reduce( - (sum, pos) => { - const totalPnl = new Big(pos.pnlCny); - const realized = new Big(pos.realizedPnlCny); - return sum.plus(totalPnl.minus(realized)); - }, - new Big('0') - ); + for (const pos of positions) { + totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0')); + totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0')); + totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0')); + } const chartData = positions.map((pos, index) => ({ name: pos.symbol, - value: new Big(pos.cnyValue).toNumber(), + value: new Big(pos.marketValueCny || '0').toNumber(), fill: [ '#3b82f6', '#8b5cf6', @@ -458,11 +450,11 @@ export async function getPortfolioSummary() { const market = getMarketFromExchange(pos.exchange); const existing = marketMap.get(market); if (existing) { - existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.cnyValue)); + existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.marketValueCny || '0')); } else { marketMap.set(market, { market, - totalCnyValue: new Big(pos.cnyValue), + totalCnyValue: new Big(pos.marketValueCny || '0'), }); } } @@ -492,7 +484,7 @@ export async function getPortfolioSummary() { positions, totalCnyValue: totalCnyValue.toString(), totalPnlCny: totalPnlCny.toString(), - unrealizedPnlCny: unrealizedPnlCny.toString(), + unrealizedPnlCny: totalFloatingPnlCny.toString(), chartData, marketAllocation, }; diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index e6734e9..bc33867 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -26,13 +26,19 @@ function getTodayInShanghai(): string { export async function recordDailySnapshot() { const positions = await getPortfolioPositions(); + // 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny const totalValueCny = positions.reduce( - (sum, pos) => sum.plus(pos.cnyValue || '0'), + (sum, pos) => sum.plus(new Big(pos.marketValueCny || '0')), new Big(0) ).toString(); + // 推导真实投入本金 CNY = 市值 - 累计盈亏 const totalCostCny = positions.reduce( - (sum, pos) => sum.plus(pos.totalCostCny || '0'), + (sum, pos) => { + const mv = new Big(pos.marketValueCny || '0'); + const ap = new Big(pos.accumulatedPnlCny || '0'); + return sum.plus(mv.minus(ap)); + }, new Big(0) ).toString(); @@ -324,21 +330,16 @@ export async function reconstructPortfolioHistory() { const priceStr = await getEffectivePrice(assetId, currentDate); const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD'; - let cnyPrice: string; - if (!priceStr) { - cnyPrice = convertPriceToCny(assetLatestPriceMap.get(assetId) || '0', baseCurrency); - } else { - cnyPrice = convertPriceToCny(priceStr, baseCurrency); - } + const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0'; - const metrics = calculateAssetMetrics(assetTxs, cnyPrice); + const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics); // 获取资产对人民币的汇率 const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1'); - // marketValue 已使用 CNY 价格计算,直接取用 - // totalInvested 基于原始币种价格,需乘以汇率折算为 CNY - const posValueCny = new Big(metrics.marketValue); + // 统一汇率折算边界:calculateAssetMetrics 输出全部为 Base Currency, + // 必须无例外地将所有金额字段乘以 fxRate 得到 CNY + const posValueCny = new Big(metrics.marketValue).times(assetFxRate); const posCostCny = new Big(metrics.totalInvested).times(assetFxRate); totalValueCny = totalValueCny.plus(posValueCny);