From 87292b107a95bea566982f8d25d431c90a6c472c Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 2 May 2026 00:21:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ledger):=20PnL=20=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=8A=A8=E6=80=81=E6=B1=87=E7=8E=87=E5=AD=97?= =?UTF-8?q?=E5=85=B8=EF=BC=8C=E5=AE=9E=E7=8E=B0=E8=B7=A8=E5=B8=81=E7=A7=8D?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=9A=84=E9=AB=98=E7=B2=BE=E5=BA=A6=E6=B3=95?= =?UTF-8?q?=E5=B8=81=E6=8A=98=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 8 +++++- src/actions/portfolio.ts | 53 +++++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/Memory.md b/Memory.md index 54372aa..5c73c0d 100644 --- a/Memory.md +++ b/Memory.md @@ -278,4 +278,10 @@ - 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。 - 核心修复:将 `avgCost: avgCost.toString()` 替换为 `avgCost: metrics.averageCost`,将 `dilutedCost: dilutedCost.toString()` 替换为 `dilutedCost: metrics.dilutedCost`。 - 同时新增 `floatingPnl` 和 `accumulatedPnl` 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。 -- 遵循 `metrics` 返回值已是 string 类型的规范,不再调用 `.toString()` 导致冗余转换。 \ No newline at end of file +- 遵循 `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` 字典(`{ CNY: 1, USD: dbUsd?.rate || 7.2, HKD: dbHkd?.rate || 0.9 }`),内置查不到时的兜底安全值。 +- 废弃 `getPortfolioPositions` 中对静态 `exchangeRates` 表的 N+1 查询:在函数顶部调用 `getLatestRatesMap()` 获取动态汇率字典,并将其转换为 `Map` 供 `calculateCnyValueFromPrice` 等下游函数继续使用。 +- 替换 PnL 映射逻辑中的静态汇率查找:将 `getRate(rateMap, holding.baseCurrency, 'CNY')` 改为直接从 `dynamicRateMap[holding.baseCurrency]` 取值,实现 O(1) 内存字典访问,消除数据库耦合。 +- 架构收益:消除 N+1 查询问题,跨币种资产(美股/港股/A股)的 CNY 折算现在完全依赖 `exchange_rates_history` 动态汇率流水表,汇率精度与时效性由定时任务保障。 \ No newline at end of file diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index b320d7c..0f9f22a 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -1,9 +1,9 @@ 'use server'; 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 { asc, eq } from 'drizzle-orm'; +import { asc, desc, eq } from 'drizzle-orm'; import { calculateAssetMetrics } from '@/utils/finance'; interface Position { @@ -64,6 +64,35 @@ interface RawRate { rate: string; } +async function getLatestRatesMap(): Promise> { + 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 { const map = new Map(); for (const r of rates) { @@ -167,13 +196,14 @@ export async function getPortfolioPositions(): Promise { .leftJoin(assets, eq(assets.id, transactions.assetId)) .orderBy(asc(transactions.executedAt)); - const rates = await db.select({ - fromCurrency: exchangeRates.fromCurrency, - toCurrency: exchangeRates.toCurrency, - rate: exchangeRates.rate, - }).from(exchangeRates); + const dynamicRateMap = await getLatestRatesMap(); - const rateMap = buildRateMap(rates); + const rateMap = new Map(); + for (const [currency, rate] of Object.entries(dynamicRateMap)) { + if (currency !== 'CNY') { + rateMap.set(`${currency}_CNY`, rate.toString()); + } + } const holdings = new Map { holding.latestPrice ); - // 获取资产对人民币的汇率 - const fxRate = new Big( - getRate(rateMap, holding.baseCurrency, 'CNY') || '1' - ); + // 从动态汇率字典获取资产对人民币的汇率 + const currencyKey = holding.baseCurrency || 'CNY'; + const fxRate = dynamicRateMap[currencyKey] || new Big(1); // 将引擎返回的原生币种金额折算为 CNY const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();