fix(ledger): 修复时光机历史汇率串用问题,实装价格向后穿透与成本兜底引擎

This commit is contained in:
2026-05-02 16:28:27 +08:00
parent 211074cd97
commit b76a6ef577
3 changed files with 202 additions and 173 deletions
+91 -89
View File
@@ -24,6 +24,90 @@ interface RateRecord {
fetchTime: Date;
}
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
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<string, RateRecord[]>();
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<string> {
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<string, RateRecord[]>();
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(),
});