Compare commits
2 Commits
b7077ec9d3
...
7ded5b7837
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ded5b7837 | |||
| 87292b107a |
19
Memory.md
19
Memory.md
@ -1,5 +1,16 @@
|
||||
# Omniledger 架构与开发记忆 (Memory)
|
||||
|
||||
## 升级时光机历史快照生成逻辑,引入就近汇率匹配策略 (Closest Rate Matching),消除因使用单一日结汇率导致的历史资产估值失真 (Task 64)
|
||||
- 在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,废弃从静态 `exchangeRates` 表获取当前汇率的旧逻辑,全面接入 `exchangeRatesHistory` 历史汇率时间序列表。
|
||||
- **架构调整**:在 `dayLoop` 循环之前,一次性加载全部 `exchangeRatesHistory` 记录到内存,按 `(fromCurrency, toCurrency)` 键分组构建 `ratesCache`(`Map<string, RateRecord[]>`),每条记录已按 `fetchTime` 升序排列。
|
||||
- **核心算法 `getClosestRateForDate(currencyPair, targetDateStr)`**:在有序数组中线性扫描,找到所有 `fetchTime <= targetDate` 的记录,返回最后一条(即最接近且小于等于目标日期的汇率),实现"就近匹配"策略。
|
||||
- **汇率路由 `getHistoricalRate(from, to, dateStr)`**:优先查找直接汇率对(如 `USD_CNY`),若无则通过 USD 交叉换算(如 `HKD_USD` × `USD_CNY`),所有查找均基于目标日期的历史汇率,保持时间一致性。
|
||||
- **循环内折算**:每个资产在每个交易日调用 `getHistoricalRate(baseCurrency, 'CNY', dateStr)` 获取当日历史汇率,替代之前静态的 `getRate(baseCurrency, 'CNY')`,确保 `posValueCny` 和 `posCostCny` 均使用真实历史汇率折算。
|
||||
- **性能保障**:汇率数据仅在循环外加载一次(O(N) 初始化),循环内每次查找为 O(M) 线性扫描(M 为每个币种对的汇率记录数,通常极小),无 N+1 查询问题。
|
||||
- 成功重新构建 1248 天历史快照,所有日期的资产估值现在使用对应日期的真实汇率,消除历史回溯失真。
|
||||
|
||||
## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)
|
||||
|
||||
## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)
|
||||
- 在 `src/db/schema.ts` 中新增 `exchangeRatesHistory` 表定义:包含 `id` (uuid)、`fromCurrency`、`toCurrency` (固定 CNY)、`rate` (numeric(20,8) 高精度)、`fetchTime` (时间戳)、`createdAt`,支持 USD/CNY 与 HKD/CNY 双币种对的汇率历史追踪。
|
||||
- 执行 `drizzle-kit push` 将新表推送到 PostgreSQL 数据库,确保表结构生效。
|
||||
@ -278,4 +289,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()` 导致冗余转换。
|
||||
- 遵循 `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';
|
||||
|
||||
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<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> {
|
||||
const map = new Map<string, string>();
|
||||
for (const r of rates) {
|
||||
@ -167,13 +196,14 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
.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<string, string>();
|
||||
for (const [currency, rate] of Object.entries(dynamicRateMap)) {
|
||||
if (currency !== 'CNY') {
|
||||
rateMap.set(`${currency}_CNY`, rate.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const holdings = new Map<string, {
|
||||
assetId: string;
|
||||
@ -330,10 +360,9 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
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();
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user