refactor(ledger): 时光机接入历史汇率匹配,实现历史快照的高精度法币回放
This commit is contained in:
+69
-36
@@ -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<string, RateRecord[]>();
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user