feat(ledger): 组装 Day-by-Day 遍历引擎,实现全量历史净值快照重建与入库
This commit is contained in:
parent
7bd2eb1e86
commit
fd0ef345dd
@ -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` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。
|
||||
- 两个函数均使用 `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` 表,确保每天仅存一条记录。
|
||||
@ -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<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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user