refactor(ledger): PnL 引擎接入动态汇率字典,实现跨币种资产的高精度法币折算
This commit is contained in:
parent
b7077ec9d3
commit
87292b107a
@ -278,4 +278,10 @@
|
|||||||
- 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。
|
- 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。
|
||||||
- 核心修复:将 `avgCost: avgCost.toString()` 替换为 `avgCost: metrics.averageCost`,将 `dilutedCost: dilutedCost.toString()` 替换为 `dilutedCost: metrics.dilutedCost`。
|
- 核心修复:将 `avgCost: avgCost.toString()` 替换为 `avgCost: metrics.averageCost`,将 `dilutedCost: dilutedCost.toString()` 替换为 `dilutedCost: metrics.dilutedCost`。
|
||||||
- 同时新增 `floatingPnl` 和 `accumulatedPnl` 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。
|
- 同时新增 `floatingPnl` 和 `accumulatedPnl` 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。
|
||||||
- 遵循 `metrics` 返回值已是 string 类型的规范,不再调用 `.toString()` 导致冗余转换。
|
- 遵循 `metrics` 返回值已是 string 类型的规范,不再调用 `.toString()` 导致冗余转换。
|
||||||
|
|
||||||
|
## 重构 portfolio API,废弃静态 asset.exchangeRate,全面接入 exchange_rates_history 动态汇率流水表,通过 O(1) 内存字典提升跨币种折算精度与性能 (Task 63b)
|
||||||
|
- 在 `src/actions/portfolio.ts` 顶部新增 `getLatestRatesMap()` 辅助函数:通过 Drizzle ORM 的 `orderBy(desc(fetchTime)).limit(1)` 分别查询 `exchangeRatesHistory` 表中 `USD→CNY` 与 `HKD→CNY` 的最新一条记录,组装为 `Record<string, Big>` 字典(`{ CNY: 1, USD: dbUsd?.rate || 7.2, HKD: dbHkd?.rate || 0.9 }`),内置查不到时的兜底安全值。
|
||||||
|
- 废弃 `getPortfolioPositions` 中对静态 `exchangeRates` 表的 N+1 查询:在函数顶部调用 `getLatestRatesMap()` 获取动态汇率字典,并将其转换为 `Map<string, string>` 供 `calculateCnyValueFromPrice` 等下游函数继续使用。
|
||||||
|
- 替换 PnL 映射逻辑中的静态汇率查找:将 `getRate(rateMap, holding.baseCurrency, 'CNY')` 改为直接从 `dynamicRateMap[holding.baseCurrency]` 取值,实现 O(1) 内存字典访问,消除数据库耦合。
|
||||||
|
- 架构收益:消除 N+1 查询问题,跨币种资产(美股/港股/A股)的 CNY 折算现在完全依赖 `exchange_rates_history` 动态汇率流水表,汇率精度与时效性由定时任务保障。
|
||||||
@ -1,9 +1,9 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { transactions, assets, exchangeRates } from '@/db/schema';
|
import { transactions, assets, exchangeRates, exchangeRatesHistory } from '@/db/schema';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { asc, eq } from 'drizzle-orm';
|
import { asc, desc, eq } from 'drizzle-orm';
|
||||||
import { calculateAssetMetrics } from '@/utils/finance';
|
import { calculateAssetMetrics } from '@/utils/finance';
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@ -64,6 +64,35 @@ interface RawRate {
|
|||||||
rate: string;
|
rate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getLatestRatesMap(): Promise<Record<string, Big>> {
|
||||||
|
const usdResult = await db
|
||||||
|
.select({
|
||||||
|
rate: exchangeRatesHistory.rate,
|
||||||
|
})
|
||||||
|
.from(exchangeRatesHistory)
|
||||||
|
.where(eq(exchangeRatesHistory.fromCurrency, 'USD'))
|
||||||
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hkdResult = await db
|
||||||
|
.select({
|
||||||
|
rate: exchangeRatesHistory.rate,
|
||||||
|
})
|
||||||
|
.from(exchangeRatesHistory)
|
||||||
|
.where(eq(exchangeRatesHistory.fromCurrency, 'HKD'))
|
||||||
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const dbUsd = usdResult[0];
|
||||||
|
const dbHkd = hkdResult[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
CNY: new Big(1),
|
||||||
|
USD: new Big(dbUsd?.rate || 7.2),
|
||||||
|
HKD: new Big(dbHkd?.rate || 0.9),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildRateMap(rates: RawRate[]): Map<string, string> {
|
function buildRateMap(rates: RawRate[]): Map<string, string> {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const r of rates) {
|
for (const r of rates) {
|
||||||
@ -167,13 +196,14 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
||||||
.orderBy(asc(transactions.executedAt));
|
.orderBy(asc(transactions.executedAt));
|
||||||
|
|
||||||
const rates = await db.select({
|
const dynamicRateMap = await getLatestRatesMap();
|
||||||
fromCurrency: exchangeRates.fromCurrency,
|
|
||||||
toCurrency: exchangeRates.toCurrency,
|
|
||||||
rate: exchangeRates.rate,
|
|
||||||
}).from(exchangeRates);
|
|
||||||
|
|
||||||
const rateMap = buildRateMap(rates);
|
const rateMap = new Map<string, string>();
|
||||||
|
for (const [currency, rate] of Object.entries(dynamicRateMap)) {
|
||||||
|
if (currency !== 'CNY') {
|
||||||
|
rateMap.set(`${currency}_CNY`, rate.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const holdings = new Map<string, {
|
const holdings = new Map<string, {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@ -330,10 +360,9 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
holding.latestPrice
|
holding.latestPrice
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取资产对人民币的汇率
|
// 从动态汇率字典获取资产对人民币的汇率
|
||||||
const fxRate = new Big(
|
const currencyKey = holding.baseCurrency || 'CNY';
|
||||||
getRate(rateMap, holding.baseCurrency, 'CNY') || '1'
|
const fxRate = dynamicRateMap[currencyKey] || new Big(1);
|
||||||
);
|
|
||||||
|
|
||||||
// 将引擎返回的原生币种金额折算为 CNY
|
// 将引擎返回的原生币种金额折算为 CNY
|
||||||
const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();
|
const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user