feat(ledger): 组装 Day-by-Day 遍历引擎,实现全量历史净值快照重建与入库
This commit is contained in:
parent
7bd2eb1e86
commit
fd0ef345dd
@ -140,3 +140,10 @@
|
|||||||
- 在 `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` 表,确保每天仅存一条记录。
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user