fix(ledger): 修复时光机历史汇率串用问题,实装价格向后穿透与成本兜底引擎
This commit is contained in:
+104
-84
@@ -213,6 +213,95 @@ export async function getEffectivePrice(
|
||||
return record?.price ?? null;
|
||||
}
|
||||
|
||||
interface RateRecord {
|
||||
rate: string;
|
||||
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 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<string, string>();
|
||||
const assetLatestPriceMap = new Map<string, string>();
|
||||
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<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,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user