fix(ledger): 修复时区黑洞,强制使用绝对字符串边界获取精准历史汇率
This commit is contained in:
parent
7cdee75bb9
commit
a5daa6a751
@ -1,5 +1,12 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# Omniledger 架构与开发记忆 (Memory)
|
||||||
|
|
||||||
|
## 废弃 JS Date 对象隐式比较,采用 SQL 字符串绝对边界 (YYYY-MM-DD 23:59:59) 重构汇率查询逻辑,彻底解决时区偏移导致的真实汇率读取失败问题 (Task 75)
|
||||||
|
- 在 `src/actions/snapshots.ts` 的 `buildDailyRatesMap` 函数中,**彻底废弃**基于 `new Date(targetDateStr + 'T23:59:59.999')` 的 JS Date 对象比较逻辑。
|
||||||
|
- **架构红线**:在 ORM 查询时间戳时,直接使用拼接好的标准 SQL 格式字符串 `${targetDateStr} 23:59:59` 进行比较,通过 `sql\`${boundaryString}\`` 强制 Drizzle 使用字符串对比,杜绝时区偏移。
|
||||||
|
- **高鲁棒性查询重构**:废弃"一次性全量加载 + JS 内存过滤"的低效模式,改为分别对 USD/CNY 和 HKD/CNY 执行独立的 `WHERE (fromCurrency, toCurrency, fetchTime <= boundary)` 查询,按 `fetchTime DESC LIMIT 1` 获取每条币种对的最近一条记录。
|
||||||
|
- **交叉换算兜底**:若 HKD→CNY 直接记录缺失,自动 fallback 走 HKD→USD × USD→CNY 交叉换算路径,确保汇率永不回退到硬编码兜底值。
|
||||||
|
- **防盲点日志**:在 return 前注入 `console.log(\`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}\`)`,终端一目了然追踪汇率抓取状态。
|
||||||
|
|
||||||
## 修复时光机引擎:1. 将汇率查询条件延展至 23:59:59 以解决跨日边界导致的数据穿透失败;2. 修复对已结算法币成本进行双重汇率乘法的严重财务逻辑 Bug (Task 74)
|
## 修复时光机引擎:1. 将汇率查询条件延展至 23:59:59 以解决跨日边界导致的数据穿透失败;2. 修复对已结算法币成本进行双重汇率乘法的严重财务逻辑 Bug (Task 74)
|
||||||
- **汇率时间边界修复**:在 `src/actions/snapshots.ts` 和 `app/api/debug/snapshot/route.ts` 的 `buildDailyRatesMap` 函数中,将 `getClosestRateForDate` 内部的时间比较边界从 `targetDateStr + 'T00:00:00Z'` 延展至 `targetDateStr + 'T23:59:59.999'`。修复根因:初始 SQL 查询使用 `lte(fetchTime, 23:59:59)` 拉取全天数据,但内层循环用次日 00:00:00 做 `<=` 截断,导致跨日时区边界下汇率数据穿透失败,美元汇率回退到 7.22 兜底值而非数据库真实的 6.82。
|
- **汇率时间边界修复**:在 `src/actions/snapshots.ts` 和 `app/api/debug/snapshot/route.ts` 的 `buildDailyRatesMap` 函数中,将 `getClosestRateForDate` 内部的时间比较边界从 `targetDateStr + 'T00:00:00Z'` 延展至 `targetDateStr + 'T23:59:59.999'`。修复根因:初始 SQL 查询使用 `lte(fetchTime, 23:59:59)` 拉取全天数据,但内层循环用次日 00:00:00 做 `<=` 截断,导致跨日时区边界下汇率数据穿透失败,美元汇率回退到 7.22 兜底值而非数据库真实的 6.82。
|
||||||
- **双重汇率乘法修复**:在 `src/utils/finance.ts` 的 `calculateAssetMetrics` 返回值中新增 `accumulatedCost` 字段(`totalInvested - totalRealized`),代表持仓净成本(Base Currency)。
|
- **双重汇率乘法修复**:在 `src/utils/finance.ts` 的 `calculateAssetMetrics` 返回值中新增 `accumulatedCost` 字段(`totalInvested - totalRealized`),代表持仓净成本(Base Currency)。
|
||||||
|
|||||||
@ -213,71 +213,75 @@ export async function getEffectivePrice(
|
|||||||
return record?.price ?? null;
|
return record?.price ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RateRecord {
|
|
||||||
rate: string;
|
|
||||||
fetchTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
|
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
|
||||||
const allRates = await db
|
const boundaryString = `${targetDateStr} 23:59:59`;
|
||||||
|
|
||||||
|
// 获取 USD/CNY — 取目标时间点之前最后一条 USD->CNY 记录
|
||||||
|
const usdRecords = await db
|
||||||
.select({
|
.select({
|
||||||
fromCurrency: exchangeRatesHistory.fromCurrency,
|
|
||||||
toCurrency: exchangeRatesHistory.toCurrency,
|
|
||||||
rate: exchangeRatesHistory.rate,
|
rate: exchangeRatesHistory.rate,
|
||||||
fetchTime: exchangeRatesHistory.fetchTime,
|
fetchTime: exchangeRatesHistory.fetchTime,
|
||||||
})
|
})
|
||||||
.from(exchangeRatesHistory)
|
.from(exchangeRatesHistory)
|
||||||
.where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59')))
|
.where(
|
||||||
.orderBy(asc(exchangeRatesHistory.fetchTime));
|
and(
|
||||||
|
eq(exchangeRatesHistory.fromCurrency, 'USD'),
|
||||||
|
eq(exchangeRatesHistory.toCurrency, 'CNY'),
|
||||||
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const ratesCache = new Map<string, RateRecord[]>();
|
// 获取 HKD/CNY — 取目标时间点之前最后一条 HKD->CNY 记录
|
||||||
for (const rec of allRates) {
|
const hkdRecords = await db
|
||||||
const key = `${rec.fromCurrency}_${rec.toCurrency}`;
|
.select({
|
||||||
if (!ratesCache.has(key)) {
|
rate: exchangeRatesHistory.rate,
|
||||||
ratesCache.set(key, []);
|
fetchTime: exchangeRatesHistory.fetchTime,
|
||||||
|
})
|
||||||
|
.from(exchangeRatesHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
|
||||||
|
eq(exchangeRatesHistory.toCurrency, 'CNY'),
|
||||||
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// 若 HKD->CNY 不存在,尝试走 HKD->USD 再 USD->CNY 的交叉换算
|
||||||
|
let hkdRateStr: string | null = hkdRecords[0]?.rate ?? null;
|
||||||
|
if (!hkdRateStr) {
|
||||||
|
const hkdUsdRecords = await db
|
||||||
|
.select({
|
||||||
|
rate: exchangeRatesHistory.rate,
|
||||||
|
fetchTime: exchangeRatesHistory.fetchTime,
|
||||||
|
})
|
||||||
|
.from(exchangeRatesHistory)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
|
||||||
|
eq(exchangeRatesHistory.toCurrency, 'USD'),
|
||||||
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const usdToCnyRate = usdRecords[0]?.rate ?? null;
|
||||||
|
if (hkdUsdRecords[0]?.rate && usdToCnyRate) {
|
||||||
|
hkdRateStr = new Big(hkdUsdRecords[0].rate).times(new Big(usdToCnyRate)).toString();
|
||||||
}
|
}
|
||||||
ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClosestRateForDate(currencyPair: string): string | null {
|
const usdRateStr = usdRecords[0]?.rate ?? null;
|
||||||
const records = ratesCache.get(currencyPair);
|
|
||||||
if (!records || records.length === 0) return null;
|
|
||||||
const endOfDay = new Date(targetDateStr + 'T23:59:59.999');
|
|
||||||
let closest: RateRecord | null = null;
|
|
||||||
for (const rec of records) {
|
|
||||||
if (rec.fetchTime <= endOfDay) {
|
|
||||||
closest = rec;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return closest?.rate ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRate(from: string, to: string): string | null {
|
console.log(`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}`);
|
||||||
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 {
|
return {
|
||||||
USD: new Big(usdRate || '7.22'),
|
USD: new Big(usdRateStr || '7.22'),
|
||||||
HKD: new Big(hkdRate || '0.92'),
|
HKD: new Big(hkdRateStr || '0.92'),
|
||||||
CNY: new Big(1),
|
CNY: new Big(1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user