From a3b5563db2d225affde75d105c889d92e7bf04c3 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Fri, 1 May 2026 04:55:43 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ledger):=20=E6=97=B6=E5=85=89=E6=9C=BA?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=85=A8=E6=96=B0=E8=B4=A2=E5=8A=A1=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E5=B9=B6=E6=B8=85=E6=B4=97=E8=84=8F=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E6=96=AD=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 6 +++++ src/actions/snapshots.ts | 56 ++++++++++++++++++++++++++++------------ src/utils/finance.ts | 3 ++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Memory.md b/Memory.md index eb19739..456cd1e 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,11 @@ # Omniledger 架构与开发记忆 (Memory) +## 重构历史快照生成逻辑,消除新旧算法断层 (Task 57) +- 将时光机重构逻辑全面接入 finance utils 引擎,清洗历史脏快照,消除新旧算法迭代导致的本金曲线断层。 +- 在 `src/utils/finance.ts` 的 `calculateAssetMetrics` 返回值中新增 `totalInvested` 字段,直接输出真实投入本金(含手续费),避免通过 `marketValue - accumulatedPnl` 间接推导导致的精度损失。 +- 在 `src/actions/snapshots.ts` 中废弃 `reconstructPortfolioHistory()` 的旧版 Day-by-Day 加减法逻辑,改为:对每一天 `currentDate`,获取该资产在 `currentDate` 及之前的所有交易流水 `historicalTx` 和历史收盘价 `historicalPrice`(断点结转),调用 `calculateAssetMetrics(historicalTx, historicalPrice)` 获取 `metrics.marketValue` 和 `metrics.totalInvested`,分别累加为当天的 `totalValueCny` 和 `totalCostCny`。 +- 重构后的 `reconstructPortfolioHistory()` 执行第一步调用 `db.delete(portfolioSnapshots)` 彻底清空旧的脏快照,然后从第一笔交易开始用新算法逐天重新生成,确保历史成本曲线平滑过渡、数值一致。 + ## 基础设施与底层架构 - 完成根目录的 Next.js 初始化、基础依赖安装与环境变量配置。 - 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。 diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index af98448..e527f33 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -5,6 +5,7 @@ import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeR import { getPortfolioPositions } from './portfolio'; import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import Big from 'big.js'; +import { calculateAssetMetrics } from '@/utils/finance'; function formatDateString(date: Date): string { const yyyy = date.getFullYear(); @@ -282,35 +283,58 @@ export async function reconstructPortfolioHistory() { return price; } + await db.delete(portfolioSnapshots); + let daysReconstructed = 0; while (formatDateString(currentDate) <= todayStr) { const dateStr = formatDateString(currentDate); - const positions = await getHistoricalPositions(currentDate); + const historicalTx = await db + .select({ + assetId: transactions.assetId, + executedAt: transactions.executedAt, + txType: transactions.txType, + quantity: transactions.quantity, + price: transactions.price, + fee: transactions.fee, + }) + .from(transactions) + .where(lte(transactions.executedAt, currentDate)) + .orderBy(asc(transactions.executedAt)); let totalValueCny = new Big('0'); let totalCostCny = new Big('0'); - for (const pos of positions) { - const priceStr = await getEffectivePrice(pos.assetId, currentDate); - const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD'; + const uniqueAssetIds = [...new Set(historicalTx.filter(t => + t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND' + ).map(t => t.assetId))]; + for (const assetId of uniqueAssetIds) { + const assetTxs = historicalTx + .filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND')) + .map(t => ({ + date: new Date(t.executedAt).toISOString().split('T')[0], + txType: t.txType, + quantity: t.quantity.toString(), + price: t.price.toString(), + fee: t.fee.toString(), + })); + + const priceStr = await getEffectivePrice(assetId, currentDate); + const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD'; + + let cnyPrice: string; if (!priceStr) { - const fallbackPrice = assetLatestPriceMap.get(pos.assetId) || '0'; - const cnyPrice = convertPriceToCny(fallbackPrice, baseCurrency); - const price = new Big(cnyPrice); - const qty = new Big(pos.quantity); - totalValueCny = totalValueCny.plus(price.times(qty)); - totalCostCny = totalCostCny.plus(pos.totalCost); - continue; + cnyPrice = convertPriceToCny(assetLatestPriceMap.get(assetId) || '0', baseCurrency); + } else { + cnyPrice = convertPriceToCny(priceStr, baseCurrency); } - const cnyPrice = convertPriceToCny(priceStr, baseCurrency); - const price = new Big(cnyPrice); - const qty = new Big(pos.quantity); - totalValueCny = totalValueCny.plus(price.times(qty)); - totalCostCny = totalCostCny.plus(pos.totalCost); + const metrics = calculateAssetMetrics(assetTxs, cnyPrice); + + totalValueCny = totalValueCny.plus(metrics.marketValue); + totalCostCny = totalCostCny.plus(metrics.totalInvested); } const existing = await db diff --git a/src/utils/finance.ts b/src/utils/finance.ts index d6959b9..10109ef 100644 --- a/src/utils/finance.ts +++ b/src/utils/finance.ts @@ -62,6 +62,7 @@ export function calculateAssetMetrics(transactions: TxRecord[], currentPrice: st dilutedCost: dilutedCost.toString(), floatingPnl: floatingPnl.toString(), accumulatedPnl: accumulatedPnl.toString(), - marketValue: currentMarketValue.toString() + marketValue: currentMarketValue.toString(), + totalInvested: totalInvested.toString() }; }