From fd0ef345dd996da0f73fa131310d440500572c6b Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Thu, 30 Apr 2026 11:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(ledger):=20=E7=BB=84=E8=A3=85=20Day-by-Day?= =?UTF-8?q?=20=E9=81=8D=E5=8E=86=E5=BC=95=E6=93=8E=EF=BC=8C=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=85=A8=E9=87=8F=E5=8E=86=E5=8F=B2=E5=87=80=E5=80=BC?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E9=87=8D=E5=BB=BA=E4=B8=8E=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 9 ++- src/actions/snapshots.ts | 137 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/Memory.md b/Memory.md index 3a16d52..e9d80e3 100644 --- a/Memory.md +++ b/Memory.md @@ -139,4 +139,11 @@ - 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。 - 在 `src/actions/snapshots.ts` 中新增 `getHistoricalPositions(targetDate)` 函数:从 `transactions` 表查询所有 `executedAt <= targetDate` 的流水,按时间正序遍历,按资产聚合计算出该日期下的 `quantity`(当前持仓)和 `totalCost`(累计投入本金,SELL 时按平均成本扣减),过滤掉已清仓资产。 - 在 `src/actions/snapshots.ts` 中新增 `getEffectivePrice(assetId, targetDate)` 函数:在 `assetPricesHistory` 表中查询指定 `assetId` 且 `date <= targetDate` 的记录,按照 `date` 降序排列 (`desc`) 并 `.limit(1)` 取第一条,实现价格断点结转(Forward-Fill)逻辑——如果目标当天没有导入价格,系统自动抓取该资产在目标日期之前「最新」的一次有效价格。 -- 两个函数均使用 `Big.js` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。 \ No newline at end of file +- 两个函数均使用 `Big.js` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。 + +## 净值时光机主引擎 - Day-by-Day 循环遍历重建 (Task 50b) +- 完成净值时光机主引擎,通过 Day-by-Day 循环遍历历史流水并结合断点结转价格,自动重建全量历史资产快照。 +- 在 `src/actions/snapshots.ts` 中新增 `reconstructPortfolioHistory()` 函数:查询 `transactions` 表找出最早的 `executedAt` 作为回溯起点,转换为 `Asia/Shanghai` 时区后以天为单位循环至今天。 +- 循环体内调用 `getHistoricalPositions(currentDate)` 获取当天所有有持仓的资产(含持仓数量与累计本金),再调用 `getEffectivePrice(assetId, currentDate)` 获取各资产的有效价格(断点结转)。 +- 引入汇率转换逻辑:预先加载 `assets` 表获取各资产的基础币种,加载 `exchangeRates` 表构建汇率映射,支持直接汇率与 USD 交叉换算,将各资产市值统一换算为 CNY。 +- 使用 `Big.js` 确保所有金额计算的高精度,按天计算 `totalValueCny`(总市值)与 `totalCostCny`(总本金),并通过 Upsert 逻辑写入 `portfolioSnapshots` 表,确保每天仅存一条记录。 \ No newline at end of file diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index d525e0e..6337adb 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -1,7 +1,7 @@ 'use server'; import { db } from '@/db'; -import { portfolioSnapshots, transactions, assetPricesHistory } from '@/db/schema'; +import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeRates } from '@/db/schema'; import { getPortfolioPositions } from './portfolio'; import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import Big from 'big.js'; @@ -197,3 +197,138 @@ export async function getEffectivePrice( return record?.price ?? null; } + +export async function reconstructPortfolioHistory() { + const [earliest] = await db + .select({ executedAt: transactions.executedAt }) + .from(transactions) + .orderBy(asc(transactions.executedAt)) + .limit(1); + + if (!earliest) { + return { + success: true, + message: 'No transactions found, nothing to reconstruct.', + daysReconstructed: 0, + }; + } + + const earliestDate = new Date(earliest.executedAt); + const utcStr = earliestDate.toLocaleString('en-US', { timeZone: 'UTC' }); + const utcDate = new Date(utcStr); + const shanghaiOffset = 8 * 60 * 60 * 1000; + const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); + let currentDate = new Date(shanghaiDate); + currentDate.setHours(0, 0, 0, 0); + + const todayStr = getTodayInShanghai(); + + const allAssets = await db + .select({ + id: assets.id, + baseCurrency: assets.baseCurrency, + }) + .from(assets); + const assetBaseCurrencyMap = new Map(); + for (const a of allAssets) { + assetBaseCurrencyMap.set(a.id, a.baseCurrency); + } + + const allRates = await db + .select({ + fromCurrency: exchangeRates.fromCurrency, + toCurrency: exchangeRates.toCurrency, + rate: exchangeRates.rate, + }) + .from(exchangeRates); + + function getRate(from: string, to: string): string | null { + const direct = allRates.find( + (r) => r.fromCurrency === from && r.toCurrency === to + ); + if (direct) return direct.rate; + const usdToCny = allRates.find( + (r) => r.fromCurrency === 'USD' && r.toCurrency === 'CNY' + ); + if (!usdToCny) return null; + const fromToUsd = allRates.find( + (r) => r.fromCurrency === from && r.toCurrency === 'USD' + ); + if (fromToUsd) { + return new Big(fromToUsd.rate).times(new Big(usdToCny.rate)).toString(); + } + return null; + } + + function convertPriceToCny(price: string, baseCurrency: string): string { + if (baseCurrency === 'CNY') { + return price; + } + const rate = getRate(baseCurrency, 'CNY'); + if (rate) { + return new Big(price).times(new Big(rate)).toString(); + } + return price; + } + + let daysReconstructed = 0; + + while (currentDate.toISOString().split('T')[0] <= todayStr) { + const dateStr = currentDate.toISOString().split('T')[0]; + + const positions = await getHistoricalPositions(currentDate); + + 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'; + if (priceStr) { + 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 existing = await db + .select() + .from(portfolioSnapshots) + .where(eq(portfolioSnapshots.date, dateStr)) + .limit(1); + + const now = new Date(); + + if (existing.length > 0) { + await db + .update(portfolioSnapshots) + .set({ + totalValueCny: totalValueCny.toString(), + totalCostCny: totalCostCny.toString(), + updatedAt: now, + }) + .where(eq(portfolioSnapshots.date, dateStr)); + } else { + await db + .insert(portfolioSnapshots) + .values({ + date: dateStr, + totalValueCny: totalValueCny.toString(), + totalCostCny: totalCostCny.toString(), + createdAt: now, + updatedAt: now, + }); + } + + daysReconstructed++; + + currentDate.setDate(currentDate.getDate() + 1); + } + + return { + success: true, + daysReconstructed, + }; +}