diff --git a/Memory.md b/Memory.md index 88e5d85..384f99a 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,12 @@ # Omniledger 架构与开发记忆 (Memory) +## 重构时光机底层引擎,引入基于 lte 的历史价格/汇率向后穿透查询,解决数据断层导致的 0 价格黑洞与汇率串用 Bug (Task 72) +- 在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,将汇率获取从"一次性全量加载"重构为"按天循环顶部动态构建":每天 `targetDate` 循环开始时调用 `buildDailyRatesMap(dateStr)`,查询 `exchange_rates_history` 中 `fetch_time <= targetDate` 的所有记录,按 `(fromCurrency, toCurrency)` 分组构建当日汇率字典,O(1) 内存访问。 +- **汇率兜底安全值**:USD → 7.22,HKD → 0.92,CNY → 1,确保新系统建的老账单查不到历史汇率时不会崩溃。 +- **价格向后穿透修复**:废弃 `getEffectivePrice` 中查不到价格时回退到 `latestPrice` 的逻辑,改为新增 `getHistoricalPriceWithFallback(assetId, dateStr, fallbackCostPrice)` 函数,使用 `lte(date, targetDate)` + `orderBy(desc)` + `limit(1)` 查询历史价格;若该资产连一笔历史价格都没有(如 NXE),将持仓成本价(`totalCost / quantity`)作为 `snapshotPrice` 传入,保证市值不归零。 +- **币种汇率精准匹配**:在资产计算循环中,严格根据 `baseCurrency` 从 `dailyRates` 字典中取值(如 USD 资产取 `dailyRates['USD']`,HKD 资产取 `dailyRates['HKD']`),彻底杜绝 USD/HKD 汇率串用问题。 +- 同步修复 `app/api/debug/snapshot/route.ts` X光机接口:废弃原有的 `getHistoricalPrice`(未 await 执行导致恒返回 null),全面接入 `buildDailyRatesMap` + `getHistoricalPriceWithFallback` 双引擎,确保调试接口与时光机引擎逻辑完全一致。 + ## 新增 /api/debug/snapshot X光透视接口,用于针对特定日期的历史资产快照进行逐笔对账,排查历史总资产异常波动的元凶 (Task 71) - 在 `app/api/debug/snapshot/route.ts` 创建 GET 接口,接收 `date` 或 `targetDate` 查询参数(默认 `2026-04-30`)。 - 复用 `src/actions/snapshots.ts` 中 `getHistoricalPositions()` 的核心持仓推演逻辑:从 `transactions` 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 `quantity`(持仓量)和 `totalCost`(累计投入成本,SELL 时按平均成本扣减)。 diff --git a/app/api/debug/snapshot/route.ts b/app/api/debug/snapshot/route.ts index cb6c127..99cdc78 100644 --- a/app/api/debug/snapshot/route.ts +++ b/app/api/debug/snapshot/route.ts @@ -24,6 +24,90 @@ interface RateRecord { fetchTime: Date; } +async function buildDailyRatesMap(targetDateStr: string): Promise> { + const allRates = await db + .select({ + fromCurrency: exchangeRatesHistory.fromCurrency, + toCurrency: exchangeRatesHistory.toCurrency, + rate: exchangeRatesHistory.rate, + fetchTime: exchangeRatesHistory.fetchTime, + }) + .from(exchangeRatesHistory) + .where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59'))) + .orderBy(asc(exchangeRatesHistory.fetchTime)); + + const ratesCache = new Map(); + for (const rec of allRates) { + const key = `${rec.fromCurrency}_${rec.toCurrency}`; + if (!ratesCache.has(key)) { + ratesCache.set(key, []); + } + ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime }); + } + + function getClosestRateForDate(currencyPair: string): string | null { + const records = ratesCache.get(currencyPair); + if (!records || records.length === 0) return null; + const targetDt = new Date(targetDateStr + 'T00:00:00Z'); + let closest: RateRecord | null = null; + for (const rec of records) { + if (rec.fetchTime <= targetDt) { + closest = rec; + } else { + break; + } + } + return closest?.rate ?? null; + } + + function resolveRate(from: string, to: string): string | null { + const directKey = `${from}_${to}`; + const directRate = getClosestRateForDate(directKey); + if (directRate) return directRate; + + const usdKey = `USD_${to}`; + const usdRate = getClosestRateForDate(usdKey); + if (!usdRate) return null; + if (from === 'USD') return usdRate; + + const fromToUsdKey = `${from}_USD`; + const fromToUsdRate = getClosestRateForDate(fromToUsdKey); + if (fromToUsdRate) { + return new Big(fromToUsdRate).times(new Big(usdRate)).toString(); + } + return null; + } + + const hkdRate = resolveRate('HKD', 'CNY'); + const usdRate = resolveRate('USD', 'CNY'); + + return { + USD: new Big(usdRate || '7.22'), + HKD: new Big(hkdRate || '0.92'), + CNY: new Big(1), + }; +} + +async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise { + const [record] = await db + .select({ price: assetPricesHistory.price }) + .from(assetPricesHistory) + .where( + and( + eq(assetPricesHistory.assetId, assetId), + lte(assetPricesHistory.date, dateStr) + ) + ) + .orderBy(desc(assetPricesHistory.date)) + .limit(1); + + if (record?.price) { + return record.price; + } + + return fallbackCostPrice; +} + export async function GET(req: Request) { const { searchParams } = new URL(req.url); const targetDateParam = searchParams.get('date') ?? searchParams.get('targetDate'); @@ -106,79 +190,7 @@ export async function GET(req: Request) { assetMap.set(a.id, { symbol: a.symbol, baseCurrency: a.baseCurrency || 'USD' }); } - const allRatesHistory = await db - .select({ - fromCurrency: exchangeRatesHistory.fromCurrency, - toCurrency: exchangeRatesHistory.toCurrency, - rate: exchangeRatesHistory.rate, - fetchTime: exchangeRatesHistory.fetchTime, - }) - .from(exchangeRatesHistory) - .orderBy(asc(exchangeRatesHistory.fetchTime)); - - const ratesCache = new Map(); - for (const rec of allRatesHistory) { - const key = `${rec.fromCurrency}_${rec.toCurrency}`; - if (!ratesCache.has(key)) { - ratesCache.set(key, []); - } - ratesCache.get(key)!.push({ - rate: rec.rate, - fetchTime: rec.fetchTime, - }); - } - - function getClosestRateForDate(currencyPair: string, dateStr: string): string | null { - const records = ratesCache.get(currencyPair); - if (!records || records.length === 0) { - return null; - } - const targetDt = new Date(dateStr + 'T00:00:00Z'); - let closest: RateRecord | null = null; - for (const rec of records) { - if (rec.fetchTime <= targetDt) { - closest = rec; - } else { - break; - } - } - 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; - } - - function getHistoricalPrice(assetId: string, dateStr: string): string | null { - const record = db - .select({ price: assetPricesHistory.price }) - .from(assetPricesHistory) - .where( - and( - eq(assetPricesHistory.assetId, assetId), - lte(assetPricesHistory.date, dateStr) - ) - ) - .orderBy(desc(assetPricesHistory.date)) - .limit(1); - return null; - } + const dailyRates = await buildDailyRatesMap(targetDateStr); const details: Array<{ symbol: string; @@ -198,26 +210,16 @@ export async function GET(req: Request) { const assetInfo = assetMap.get(assetId); if (!assetInfo) continue; - const priceRecord = await db - .select({ price: assetPricesHistory.price }) - .from(assetPricesHistory) - .where( - and( - eq(assetPricesHistory.assetId, assetId), - lte(assetPricesHistory.date, targetDateStr) - ) - ) - .orderBy(desc(assetPricesHistory.date)) - .limit(1); + const costPrice = holding.totalCost.div(holding.quantity).toString(); - const snapshotPrice = priceRecord[0]?.price ?? '0'; + const snapshotPrice = await getHistoricalPriceWithFallback(assetId, targetDateStr, costPrice); - const fxRate = getHistoricalRate(assetInfo.baseCurrency, 'CNY', targetDateStr) - ?? (assetInfo.baseCurrency === 'CNY' ? '1' : '7.2'); + const currency = (assetInfo.baseCurrency || 'CNY').toUpperCase(); + const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1); const qtyNum = Number(holding.quantity.toString()); const priceNum = new Big(snapshotPrice); - const fxNum = new Big(fxRate); + const fxNum = snapshotFxRate; const calcMarketValueCny = holding.quantity.times(priceNum).times(fxNum); const calcCostCny = holding.totalCost.times(fxNum); @@ -229,7 +231,7 @@ export async function GET(req: Request) { symbol: assetInfo.symbol, quantity: qtyNum, snapshotPrice: new Big(snapshotPrice).toString(), - snapshotFxRate: new Big(fxRate).toString(), + snapshotFxRate: new Big(fxNum.toString()).toString(), calculatedMarketValueCny: calcMarketValueCny.toString(), calculatedCostCny: calcCostCny.toString(), }); diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index fe681b6..b071c5c 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -213,6 +213,95 @@ export async function getEffectivePrice( return record?.price ?? null; } +interface RateRecord { + rate: string; + fetchTime: Date; +} + +async function buildDailyRatesMap(targetDateStr: string): Promise> { + const allRates = await db + .select({ + fromCurrency: exchangeRatesHistory.fromCurrency, + toCurrency: exchangeRatesHistory.toCurrency, + rate: exchangeRatesHistory.rate, + fetchTime: exchangeRatesHistory.fetchTime, + }) + .from(exchangeRatesHistory) + .where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59'))) + .orderBy(asc(exchangeRatesHistory.fetchTime)); + + const ratesCache = new Map(); + for (const rec of allRates) { + const key = `${rec.fromCurrency}_${rec.toCurrency}`; + if (!ratesCache.has(key)) { + ratesCache.set(key, []); + } + ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime }); + } + + function getClosestRateForDate(currencyPair: string): string | null { + const records = ratesCache.get(currencyPair); + if (!records || records.length === 0) return null; + const targetDt = new Date(targetDateStr + 'T00:00:00Z'); + let closest: RateRecord | null = null; + for (const rec of records) { + if (rec.fetchTime <= targetDt) { + closest = rec; + } else { + break; + } + } + return closest?.rate ?? null; + } + + function resolveRate(from: string, to: string): string | null { + const directKey = `${from}_${to}`; + const directRate = getClosestRateForDate(directKey); + if (directRate) return directRate; + + const usdKey = `USD_${to}`; + const usdRate = getClosestRateForDate(usdKey); + if (!usdRate) return null; + if (from === 'USD') return usdRate; + + const fromToUsdKey = `${from}_USD`; + const fromToUsdRate = getClosestRateForDate(fromToUsdKey); + if (fromToUsdRate) { + return new Big(fromToUsdRate).times(new Big(usdRate)).toString(); + } + return null; + } + + const hkdRate = resolveRate('HKD', 'CNY'); + const usdRate = resolveRate('USD', 'CNY'); + + return { + USD: new Big(usdRate || '7.22'), + HKD: new Big(hkdRate || '0.92'), + CNY: new Big(1), + }; +} + +async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise { + const [record] = await db + .select({ price: assetPricesHistory.price }) + .from(assetPricesHistory) + .where( + and( + eq(assetPricesHistory.assetId, assetId), + lte(assetPricesHistory.date, dateStr) + ) + ) + .orderBy(desc(assetPricesHistory.date)) + .limit(1); + + if (record?.price) { + return record.price; + } + + return fallbackCostPrice; +} + export async function reconstructPortfolioHistory() { const [earliest] = await db .select({ executedAt: transactions.executedAt }) @@ -242,85 +331,11 @@ export async function reconstructPortfolioHistory() { .select({ id: assets.id, baseCurrency: assets.baseCurrency, - latestPrice: assets.latestPrice, }) .from(assets); const assetBaseCurrencyMap = new Map(); - const assetLatestPriceMap = new Map(); for (const a of allAssets) { assetBaseCurrencyMap.set(a.id, a.baseCurrency); - assetLatestPriceMap.set(a.id, a.latestPrice || '0'); - } - - const allRatesHistory = await db - .select({ - fromCurrency: exchangeRatesHistory.fromCurrency, - toCurrency: exchangeRatesHistory.toCurrency, - rate: exchangeRatesHistory.rate, - fetchTime: exchangeRatesHistory.fetchTime, - }) - .from(exchangeRatesHistory) - .orderBy(asc(exchangeRatesHistory.fetchTime)); - - // 构建汇率缓存:按 (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, []); - } - ratesCache.get(key)!.push({ - rate: rec.rate, - fetchTime: rec.fetchTime, - }); - } - - function getClosestRateForDate( - currencyPair: string, - targetDateStr: string - ): string | null { - const records = ratesCache.get(currencyPair); - if (!records || records.length === 0) { - return null; - } - - 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 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); @@ -346,6 +361,8 @@ export async function reconstructPortfolioHistory() { let totalValueCny = new Big('0'); let totalCostCny = new Big('0'); + const dailyRates = await buildDailyRatesMap(dateStr); + const uniqueAssetIds = [...new Set(historicalTx.filter(t => t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND' ).map(t => t.assetId))]; @@ -361,23 +378,26 @@ export async function reconstructPortfolioHistory() { fee: t.fee.toString(), })); - const priceStr = await getEffectivePrice(assetId, currentDate); const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD'; - const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0'; + const costPrice = new Big(assetTxs.reduce((sum, t) => { + if (t.txType === 'BUY') return sum.plus(new Big(t.price).times(new Big(t.quantity))); + if (t.txType === 'SELL') return sum.minus(new Big(t.price).times(new Big(t.quantity))); + return sum; + }, new Big('0')).div(new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).gt(0) ? new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).toString() : '1')).toString(); + const snapshotPrice = await getHistoricalPriceWithFallback(assetId, dateStr, costPrice); + + const currency = (baseCurrency || 'CNY').toUpperCase(); + const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1); + + const priceStrForMetrics = snapshotPrice; const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics); - // 使用历史汇率就近匹配策略获取当日汇率 - const assetFxRate = new Big(getHistoricalRate(baseCurrency, 'CNY', dateStr) || '1'); - - // 市值和本金双双乘以历史汇率 - const posValueCny = new Big(metrics.marketValue).times(assetFxRate); - - // 投入本金 = (市值 - 累计盈亏) * 历史汇率,确保逻辑自洽 + const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate); const posCostCny = new Big(metrics.marketValue) .minus(metrics.accumulatedPnl) - .times(assetFxRate); + .times(snapshotFxRate); totalValueCny = totalValueCny.plus(posValueCny); totalCostCny = totalCostCny.plus(posCostCny);