diff --git a/Memory.md b/Memory.md index 5c73c0d..a25ecf5 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,16 @@ # Omniledger 架构与开发记忆 (Memory) +## 升级时光机历史快照生成逻辑,引入就近汇率匹配策略 (Closest Rate Matching),消除因使用单一日结汇率导致的历史资产估值失真 (Task 64) +- 在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,废弃从静态 `exchangeRates` 表获取当前汇率的旧逻辑,全面接入 `exchangeRatesHistory` 历史汇率时间序列表。 +- **架构调整**:在 `dayLoop` 循环之前,一次性加载全部 `exchangeRatesHistory` 记录到内存,按 `(fromCurrency, toCurrency)` 键分组构建 `ratesCache`(`Map`),每条记录已按 `fetchTime` 升序排列。 +- **核心算法 `getClosestRateForDate(currencyPair, targetDateStr)`**:在有序数组中线性扫描,找到所有 `fetchTime <= targetDate` 的记录,返回最后一条(即最接近且小于等于目标日期的汇率),实现"就近匹配"策略。 +- **汇率路由 `getHistoricalRate(from, to, dateStr)`**:优先查找直接汇率对(如 `USD_CNY`),若无则通过 USD 交叉换算(如 `HKD_USD` × `USD_CNY`),所有查找均基于目标日期的历史汇率,保持时间一致性。 +- **循环内折算**:每个资产在每个交易日调用 `getHistoricalRate(baseCurrency, 'CNY', dateStr)` 获取当日历史汇率,替代之前静态的 `getRate(baseCurrency, 'CNY')`,确保 `posValueCny` 和 `posCostCny` 均使用真实历史汇率折算。 +- **性能保障**:汇率数据仅在循环外加载一次(O(N) 初始化),循环内每次查找为 O(M) 线性扫描(M 为每个币种对的汇率记录数,通常极小),无 N+1 查询问题。 +- 成功重新构建 1248 天历史快照,所有日期的资产估值现在使用对应日期的真实汇率,消除历史回溯失真。 + +## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a) + ## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a) - 在 `src/db/schema.ts` 中新增 `exchangeRatesHistory` 表定义:包含 `id` (uuid)、`fromCurrency`、`toCurrency` (固定 CNY)、`rate` (numeric(20,8) 高精度)、`fetchTime` (时间戳)、`createdAt`,支持 USD/CNY 与 HKD/CNY 双币种对的汇率历史追踪。 - 执行 `drizzle-kit push` 将新表推送到 PostgreSQL 数据库,确保表结构生效。 diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index 653cf74..2604857 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -1,7 +1,7 @@ 'use server'; import { db } from '@/db'; -import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeRates } from '@/db/schema'; +import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeRatesHistory } from '@/db/schema'; import { getPortfolioPositions } from './portfolio'; import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import Big from 'big.js'; @@ -252,41 +252,75 @@ export async function reconstructPortfolioHistory() { assetLatestPriceMap.set(a.id, a.latestPrice || '0'); } - const allRates = await db + const allRatesHistory = await db .select({ - fromCurrency: exchangeRates.fromCurrency, - toCurrency: exchangeRates.toCurrency, - rate: exchangeRates.rate, + fromCurrency: exchangeRatesHistory.fromCurrency, + toCurrency: exchangeRatesHistory.toCurrency, + rate: exchangeRatesHistory.rate, + fetchTime: exchangeRatesHistory.fetchTime, }) - .from(exchangeRates); + .from(exchangeRatesHistory) + .orderBy(asc(exchangeRatesHistory.fetchTime)); - 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(); + // 构建汇率缓存:按 (fromCurrency, toCurrency) 分组,fetchTime 已升序排列 + interface RateRecord { + rate: string; + fetchTime: Date; + } + const ratesCache = new Map(); + + for (const rec of allRatesHistory) { + const key = `${rec.fromCurrency}_${rec.toCurrency}`; + if (!ratesCache.has(key)) { + ratesCache.set(key, []); } - return null; + ratesCache.get(key)!.push({ + rate: rec.rate, + fetchTime: rec.fetchTime, + }); } - function convertPriceToCny(price: string, baseCurrency: string): string { - if (baseCurrency === 'CNY') { - return price; + function getClosestRateForDate( + currencyPair: string, + targetDateStr: string + ): string | null { + const records = ratesCache.get(currencyPair); + if (!records || records.length === 0) { + return null; } - const rate = getRate(baseCurrency, 'CNY'); - if (rate) { - return new Big(price).times(new Big(rate)).toString(); + + const targetDate = new Date(targetDateStr + 'T00:00:00Z'); + let closest: RateRecord | null = null; + + for (const rec of records) { + if (rec.fetchTime <= targetDate) { + closest = rec; + } else { + break; + } } - return price; + + return closest?.rate ?? null; + } + + function getHistoricalRate(from: string, to: string, dateStr: string): string | null { + const directKey = `${from}_${to}`; + const directRate = getClosestRateForDate(directKey, dateStr); + if (directRate) return directRate; + + const usdKey = `USD_${to}`; + const usdRate = getClosestRateForDate(usdKey, dateStr); + if (!usdRate) return null; + + if (from === 'USD') return usdRate; + + const fromToUsdKey = `${from}_USD`; + const fromToUsdRate = getClosestRateForDate(fromToUsdKey, dateStr); + if (fromToUsdRate) { + return new Big(fromToUsdRate).times(new Big(usdRate)).toString(); + } + + return null; } await db.delete(portfolioSnapshots); @@ -333,15 +367,14 @@ export async function reconstructPortfolioHistory() { const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0'; const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics); - - // 1. 获取基础币种数据 - // 2. 获取当前资产的汇率 (必须确保能获取到,比如从 asset 表或 rateMap) - const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1'); - - // 3. 【核心修复】:市值和本金,必须双双乘以汇率! + + // 使用历史汇率就近匹配策略获取当日汇率 + const assetFxRate = new Big(getHistoricalRate(baseCurrency, 'CNY', dateStr) || '1'); + + // 市值和本金双双乘以历史汇率 const posValueCny = new Big(metrics.marketValue).times(assetFxRate); - - // 投入本金 = (市值 - 累计盈亏) * 汇率,确保逻辑自洽 + + // 投入本金 = (市值 - 累计盈亏) * 历史汇率,确保逻辑自洽 const posCostCny = new Big(metrics.marketValue) .minus(metrics.accumulatedPnl) .times(assetFxRate);