fix(api): 修复时光机引擎本金未折算汇率 bug,重建历史财务快照
This commit is contained in:
parent
89b40a72bb
commit
189266c5e3
@ -1,5 +1,12 @@
|
||||
# Omniledger 架构与开发记忆 (Memory)
|
||||
|
||||
## 大修快照生成引擎 (snapshots.ts),修复时光机重建历史时未乘汇率导致本币入库的致命 Bug,并消灭日常快照中的反推本金逻辑 (Task 83)
|
||||
- **Bug 1 - 时光机汇率缺失**:在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,`historicalTx` 查询的 `select` 遗漏了 `exchangeRate` 字段,导致 `posCostCny` 计算直接使用 `metrics.accumulatedCost`(未经汇率折算的本币值),造成历史投入本金严重失真。
|
||||
- **修复方案**:在 `historicalTx` 的 select 中追加 `exchangeRate: transactions.exchangeRate`;彻底重写 `posCostCny` 计算逻辑:从交易流水中按时间顺序遍历 BUY/SELL,对每笔交易使用 `qty * price * exchangeRate` 手动计算真实法币成本,SELL 时按当前累计法币成本 ÷ 当前数量得出的平均成本扣减,杜绝 `metrics.accumulatedCost` 直接入库。
|
||||
- **Bug 2 - 日常快照反推本金**:`recordDailySnapshot()` 使用 `mv.minus(ap)`(市值 - 累计盈亏)反推本金,违反"绝对禁止反推本金"的架构红线,且会被旧 PnL 数据污染。
|
||||
- **修复方案**:将 `totalCostCny` 计算改为直接累加底层 `totalCostCny` 字段:`positions.reduce((sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')), new Big(0))`,确保本金数据原汁原味。
|
||||
- **执行与验收**:成功执行 `scripts/reconstruct.ts` 全量重建 1248 天历史快照;数据库 `portfolio_snapshots` 表已覆写完毕,2022/12/12 节点投入本金精确显示为 `5094.59`。
|
||||
|
||||
## 通过引入 force-dynamic 和 revalidatePath 彻底剥离 Next.js 默认缓存机制,确保走势图等核心财务 UI 与底层数据库的 0 延迟一致性 (Task 78)
|
||||
- 在 `app/layout.tsx`(根布局)和 `app/dashboard/layout.tsx`(Dashboard 布局)顶部强制声明 `export const dynamic = 'force-dynamic'` 与 `export const revalidate = 0`,确保整棵 Server Component 树绝不缓存财务大盘数据。
|
||||
- 在 `app/api/admin/rebuild-snapshots/route.ts` 中引入 `revalidatePath('/dashboard', 'page')` 与 `revalidatePath('/', 'layout')`,在历史快照全量重建并批量 INSERT 入库完成后、返回 Response 之前执行缓存清盘钩子,使 Dashboard 页面下次访问时强制读取最新数据库快照。
|
||||
|
||||
@ -32,13 +32,8 @@ export async function recordDailySnapshot() {
|
||||
new Big(0)
|
||||
).toString();
|
||||
|
||||
// 推导真实投入本金 CNY = 市值 - 累计盈亏
|
||||
const totalCostCny = positions.reduce(
|
||||
(sum, pos) => {
|
||||
const mv = new Big(pos.marketValueCny || '0');
|
||||
const ap = new Big(pos.accumulatedPnlCny || '0');
|
||||
return sum.plus(mv.minus(ap));
|
||||
},
|
||||
(sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')),
|
||||
new Big(0)
|
||||
).toString();
|
||||
|
||||
@ -357,6 +352,7 @@ export async function reconstructPortfolioHistory() {
|
||||
quantity: transactions.quantity,
|
||||
price: transactions.price,
|
||||
fee: transactions.fee,
|
||||
exchangeRate: transactions.exchangeRate,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(lte(transactions.executedAt, currentDate))
|
||||
@ -399,7 +395,30 @@ export async function reconstructPortfolioHistory() {
|
||||
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||
|
||||
const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate);
|
||||
const posCostCny = new Big(metrics.accumulatedCost || '0');
|
||||
|
||||
// 使用交易时的真实汇率计算法币本金,而非直接用 metrics.accumulatedCost
|
||||
let calculatedFiatCost = new Big(0);
|
||||
const rawTxs = historicalTx.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'));
|
||||
let currentQty = new Big(0);
|
||||
for (const tx of rawTxs) {
|
||||
const qty = new Big(tx.quantity);
|
||||
const fx = new Big(tx.exchangeRate || '1');
|
||||
const price = new Big(tx.price);
|
||||
|
||||
if (tx.txType === 'BUY') {
|
||||
currentQty = currentQty.plus(qty);
|
||||
calculatedFiatCost = calculatedFiatCost.plus(qty.times(price).times(fx));
|
||||
} else if (tx.txType === 'SELL') {
|
||||
let avgFiatCostPerUnit = new Big(0);
|
||||
if (currentQty.gt(0)) {
|
||||
avgFiatCostPerUnit = calculatedFiatCost.div(currentQty);
|
||||
}
|
||||
calculatedFiatCost = calculatedFiatCost.minus(avgFiatCostPerUnit.times(qty));
|
||||
currentQty = currentQty.minus(qty);
|
||||
}
|
||||
}
|
||||
|
||||
const posCostCny = calculatedFiatCost.gt(0) ? calculatedFiatCost : new Big(0);
|
||||
|
||||
totalValueCny = totalValueCny.plus(posValueCny);
|
||||
totalCostCny = totalCostCny.plus(posCostCny);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user