feat(ledger): 组装 Day-by-Day 遍历引擎,实现全量历史净值快照重建与入库

This commit is contained in:
kennethcheng 2026-04-30 11:17:03 +08:00
parent 7bd2eb1e86
commit fd0ef345dd
2 changed files with 144 additions and 2 deletions

View File

@ -139,4 +139,11 @@
- 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。 - 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。
- 在 `src/actions/snapshots.ts` 中新增 `getHistoricalPositions(targetDate)` 函数:从 `transactions` 表查询所有 `executedAt <= targetDate` 的流水,按时间正序遍历,按资产聚合计算出该日期下的 `quantity`(当前持仓)和 `totalCost`累计投入本金SELL 时按平均成本扣减),过滤掉已清仓资产。 - 在 `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逻辑——如果目标当天没有导入价格系统自动抓取该资产在目标日期之前「最新」的一次有效价格。 - 在 `src/actions/snapshots.ts` 中新增 `getEffectivePrice(assetId, targetDate)` 函数:在 `assetPricesHistory` 表中查询指定 `assetId``date <= targetDate` 的记录,按照 `date` 降序排列 (`desc`) 并 `.limit(1)` 取第一条实现价格断点结转Forward-Fill逻辑——如果目标当天没有导入价格系统自动抓取该资产在目标日期之前「最新」的一次有效价格。
- 两个函数均使用 `Big.js` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。 - 两个函数均使用 `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` 表,确保每天仅存一条记录。

View File

@ -1,7 +1,7 @@
'use server'; 'use server';
import { db } from '@/db'; 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 { getPortfolioPositions } from './portfolio';
import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
import Big from 'big.js'; import Big from 'big.js';
@ -197,3 +197,138 @@ export async function getEffectivePrice(
return record?.price ?? null; 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<string, string>();
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,
};
}