fix(ledger): 修复时光机历史汇率串用问题,实装价格向后穿透与成本兜底引擎
This commit is contained in:
parent
211074cd97
commit
b76a6ef577
@ -1,5 +1,12 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# 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)
|
## 新增 /api/debug/snapshot X光透视接口,用于针对特定日期的历史资产快照进行逐笔对账,排查历史总资产异常波动的元凶 (Task 71)
|
||||||
- 在 `app/api/debug/snapshot/route.ts` 创建 GET 接口,接收 `date` 或 `targetDate` 查询参数(默认 `2026-04-30`)。
|
- 在 `app/api/debug/snapshot/route.ts` 创建 GET 接口,接收 `date` 或 `targetDate` 查询参数(默认 `2026-04-30`)。
|
||||||
- 复用 `src/actions/snapshots.ts` 中 `getHistoricalPositions()` 的核心持仓推演逻辑:从 `transactions` 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 `quantity`(持仓量)和 `totalCost`(累计投入成本,SELL 时按平均成本扣减)。
|
- 复用 `src/actions/snapshots.ts` 中 `getHistoricalPositions()` 的核心持仓推演逻辑:从 `transactions` 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 `quantity`(持仓量)和 `totalCost`(累计投入成本,SELL 时按平均成本扣减)。
|
||||||
|
|||||||
@ -24,6 +24,90 @@ interface RateRecord {
|
|||||||
fetchTime: Date;
|
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) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const targetDateParam = searchParams.get('date') ?? searchParams.get('targetDate');
|
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' });
|
assetMap.set(a.id, { symbol: a.symbol, baseCurrency: a.baseCurrency || 'USD' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRatesHistory = await db
|
const dailyRates = await buildDailyRatesMap(targetDateStr);
|
||||||
.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 details: Array<{
|
const details: Array<{
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@ -198,26 +210,16 @@ export async function GET(req: Request) {
|
|||||||
const assetInfo = assetMap.get(assetId);
|
const assetInfo = assetMap.get(assetId);
|
||||||
if (!assetInfo) continue;
|
if (!assetInfo) continue;
|
||||||
|
|
||||||
const priceRecord = await db
|
const costPrice = holding.totalCost.div(holding.quantity).toString();
|
||||||
.select({ price: assetPricesHistory.price })
|
|
||||||
.from(assetPricesHistory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(assetPricesHistory.assetId, assetId),
|
|
||||||
lte(assetPricesHistory.date, targetDateStr)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(assetPricesHistory.date))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const snapshotPrice = priceRecord[0]?.price ?? '0';
|
const snapshotPrice = await getHistoricalPriceWithFallback(assetId, targetDateStr, costPrice);
|
||||||
|
|
||||||
const fxRate = getHistoricalRate(assetInfo.baseCurrency, 'CNY', targetDateStr)
|
const currency = (assetInfo.baseCurrency || 'CNY').toUpperCase();
|
||||||
?? (assetInfo.baseCurrency === 'CNY' ? '1' : '7.2');
|
const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1);
|
||||||
|
|
||||||
const qtyNum = Number(holding.quantity.toString());
|
const qtyNum = Number(holding.quantity.toString());
|
||||||
const priceNum = new Big(snapshotPrice);
|
const priceNum = new Big(snapshotPrice);
|
||||||
const fxNum = new Big(fxRate);
|
const fxNum = snapshotFxRate;
|
||||||
|
|
||||||
const calcMarketValueCny = holding.quantity.times(priceNum).times(fxNum);
|
const calcMarketValueCny = holding.quantity.times(priceNum).times(fxNum);
|
||||||
const calcCostCny = holding.totalCost.times(fxNum);
|
const calcCostCny = holding.totalCost.times(fxNum);
|
||||||
@ -229,7 +231,7 @@ export async function GET(req: Request) {
|
|||||||
symbol: assetInfo.symbol,
|
symbol: assetInfo.symbol,
|
||||||
quantity: qtyNum,
|
quantity: qtyNum,
|
||||||
snapshotPrice: new Big(snapshotPrice).toString(),
|
snapshotPrice: new Big(snapshotPrice).toString(),
|
||||||
snapshotFxRate: new Big(fxRate).toString(),
|
snapshotFxRate: new Big(fxNum.toString()).toString(),
|
||||||
calculatedMarketValueCny: calcMarketValueCny.toString(),
|
calculatedMarketValueCny: calcMarketValueCny.toString(),
|
||||||
calculatedCostCny: calcCostCny.toString(),
|
calculatedCostCny: calcCostCny.toString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -213,6 +213,95 @@ 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>> {
|
||||||
|
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() {
|
export async function reconstructPortfolioHistory() {
|
||||||
const [earliest] = await db
|
const [earliest] = await db
|
||||||
.select({ executedAt: transactions.executedAt })
|
.select({ executedAt: transactions.executedAt })
|
||||||
@ -242,85 +331,11 @@ export async function reconstructPortfolioHistory() {
|
|||||||
.select({
|
.select({
|
||||||
id: assets.id,
|
id: assets.id,
|
||||||
baseCurrency: assets.baseCurrency,
|
baseCurrency: assets.baseCurrency,
|
||||||
latestPrice: assets.latestPrice,
|
|
||||||
})
|
})
|
||||||
.from(assets);
|
.from(assets);
|
||||||
const assetBaseCurrencyMap = new Map<string, string>();
|
const assetBaseCurrencyMap = new Map<string, string>();
|
||||||
const assetLatestPriceMap = new Map<string, string>();
|
|
||||||
for (const a of allAssets) {
|
for (const a of allAssets) {
|
||||||
assetBaseCurrencyMap.set(a.id, a.baseCurrency);
|
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);
|
await db.delete(portfolioSnapshots);
|
||||||
@ -346,6 +361,8 @@ export async function reconstructPortfolioHistory() {
|
|||||||
let totalValueCny = new Big('0');
|
let totalValueCny = new Big('0');
|
||||||
let totalCostCny = new Big('0');
|
let totalCostCny = new Big('0');
|
||||||
|
|
||||||
|
const dailyRates = await buildDailyRatesMap(dateStr);
|
||||||
|
|
||||||
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
||||||
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
||||||
).map(t => t.assetId))];
|
).map(t => t.assetId))];
|
||||||
@ -361,23 +378,26 @@ export async function reconstructPortfolioHistory() {
|
|||||||
fee: t.fee.toString(),
|
fee: t.fee.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const priceStr = await getEffectivePrice(assetId, currentDate);
|
|
||||||
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
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 metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||||
|
|
||||||
// 使用历史汇率就近匹配策略获取当日汇率
|
const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate);
|
||||||
const assetFxRate = new Big(getHistoricalRate(baseCurrency, 'CNY', dateStr) || '1');
|
|
||||||
|
|
||||||
// 市值和本金双双乘以历史汇率
|
|
||||||
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
|
||||||
|
|
||||||
// 投入本金 = (市值 - 累计盈亏) * 历史汇率,确保逻辑自洽
|
|
||||||
const posCostCny = new Big(metrics.marketValue)
|
const posCostCny = new Big(metrics.marketValue)
|
||||||
.minus(metrics.accumulatedPnl)
|
.minus(metrics.accumulatedPnl)
|
||||||
.times(assetFxRate);
|
.times(snapshotFxRate);
|
||||||
|
|
||||||
totalValueCny = totalValueCny.plus(posValueCny);
|
totalValueCny = totalValueCny.plus(posValueCny);
|
||||||
totalCostCny = totalCostCny.plus(posCostCny);
|
totalCostCny = totalCostCny.plus(posCostCny);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user