refactor(ledger): 时光机接入全新财务引擎并清洗脏快照,修复历史成本断层
This commit is contained in:
parent
f537dcf303
commit
a3b5563db2
@ -1,5 +1,11 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# 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 初始化、基础依赖安装与环境变量配置。
|
- 完成根目录的 Next.js 初始化、基础依赖安装与环境变量配置。
|
||||||
- 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。
|
- 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeR
|
|||||||
import { getPortfolioPositions } from './portfolio';
|
import { getPortfolioPositions } from './portfolio';
|
||||||
import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
|
import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import { calculateAssetMetrics } from '@/utils/finance';
|
||||||
|
|
||||||
function formatDateString(date: Date): string {
|
function formatDateString(date: Date): string {
|
||||||
const yyyy = date.getFullYear();
|
const yyyy = date.getFullYear();
|
||||||
@ -282,35 +283,58 @@ export async function reconstructPortfolioHistory() {
|
|||||||
return price;
|
return price;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db.delete(portfolioSnapshots);
|
||||||
|
|
||||||
let daysReconstructed = 0;
|
let daysReconstructed = 0;
|
||||||
|
|
||||||
while (formatDateString(currentDate) <= todayStr) {
|
while (formatDateString(currentDate) <= todayStr) {
|
||||||
const dateStr = formatDateString(currentDate);
|
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 totalValueCny = new Big('0');
|
||||||
let totalCostCny = new Big('0');
|
let totalCostCny = new Big('0');
|
||||||
|
|
||||||
for (const pos of positions) {
|
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
||||||
const priceStr = await getEffectivePrice(pos.assetId, currentDate);
|
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
||||||
const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD';
|
).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) {
|
if (!priceStr) {
|
||||||
const fallbackPrice = assetLatestPriceMap.get(pos.assetId) || '0';
|
cnyPrice = convertPriceToCny(assetLatestPriceMap.get(assetId) || '0', baseCurrency);
|
||||||
const cnyPrice = convertPriceToCny(fallbackPrice, baseCurrency);
|
} else {
|
||||||
const price = new Big(cnyPrice);
|
cnyPrice = convertPriceToCny(priceStr, baseCurrency);
|
||||||
const qty = new Big(pos.quantity);
|
|
||||||
totalValueCny = totalValueCny.plus(price.times(qty));
|
|
||||||
totalCostCny = totalCostCny.plus(pos.totalCost);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cnyPrice = convertPriceToCny(priceStr, baseCurrency);
|
const metrics = calculateAssetMetrics(assetTxs, cnyPrice);
|
||||||
const price = new Big(cnyPrice);
|
|
||||||
const qty = new Big(pos.quantity);
|
totalValueCny = totalValueCny.plus(metrics.marketValue);
|
||||||
totalValueCny = totalValueCny.plus(price.times(qty));
|
totalCostCny = totalCostCny.plus(metrics.totalInvested);
|
||||||
totalCostCny = totalCostCny.plus(pos.totalCost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export function calculateAssetMetrics(transactions: TxRecord[], currentPrice: st
|
|||||||
dilutedCost: dilutedCost.toString(),
|
dilutedCost: dilutedCost.toString(),
|
||||||
floatingPnl: floatingPnl.toString(),
|
floatingPnl: floatingPnl.toString(),
|
||||||
accumulatedPnl: accumulatedPnl.toString(),
|
accumulatedPnl: accumulatedPnl.toString(),
|
||||||
marketValue: currentMarketValue.toString()
|
marketValue: currentMarketValue.toString(),
|
||||||
|
totalInvested: totalInvested.toString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user