Compare commits
3 Commits
f537dcf303
...
b4f21e7cd6
| Author | SHA1 | Date | |
|---|---|---|---|
| b4f21e7cd6 | |||
| 52a94a9ffa | |||
| a3b5563db2 |
19
Memory.md
19
Memory.md
@ -1,5 +1,24 @@
|
||||
# Omniledger 架构与开发记忆 (Memory)
|
||||
|
||||
## 修复 calculateAssetMetrics 结果的汇率双重标准解析错误,重构 Live Overview 聚合基准 (Task 59)
|
||||
- **核心认知纠正:** `calculateAssetMetrics` 引擎产出的所有数据(`marketValue`, `accumulatedPnl`, `floatingPnl`, `dilutedCost` 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。
|
||||
- 修复 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()`:之前错误地将 `cnyPrice`(已折算的人民币价格)传入引擎,却只对 `totalInvested` 乘汇率、对 `marketValue` 不乘,形成"双标幻觉"。现改为传入原始币种价格 `priceStr`,然后无例外地将引擎输出的所有金额字段统一乘以 `fxRate` 得到 CNY,确保 `posValueCny` 和 `posCostCny` 使用同一套汇率折算基准。
|
||||
- 修复 `src/actions/portfolio.ts` 的 `getPortfolioSummary()`:废弃旧的 `cnyValue`/`pnlCny` 求和逻辑(旧逻辑基于 `calculateCnyValueFromPrice` 双路径计算,与引擎输出存在语义差异),改为从 `getPortfolioPositions()` 返回的 `marketValueCny`/`accumulatedPnlCny`/`floatingPnlCny` 字段在内存中累加,确保大盘汇总与底层明细使用同一套计算源(单一事实来源)。
|
||||
- 同步修复 `recordDailySnapshot()`:`totalValueCny` 改为累加 `marketValueCny`,`totalCostCny` 改为 `marketValueCny - accumulatedPnlCny` 推导,与 `getPortfolioSummary` 保持一致。
|
||||
- 彻底覆盖历史快照:重新执行 `reconstructPortfolioHistory()`,成功重构 1247 天历史快照数据,消除之前因混用汇率导致的错乱快照。
|
||||
|
||||
## 全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58)
|
||||
- 修复了跨币种资产直接相加导致的盈亏总额失真问题:USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算,HKD 亏损同理。
|
||||
- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 中,对每个资产获取 `exchangeRate`(Base Currency → CNY),将财务引擎 (`calculateAssetMetrics`) 产出的所有绝对金额字段(`marketValue`、`floatingPnl`、`accumulatedPnl`、`dilutedCost`)乘以汇率,映射为 `Cny` 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。
|
||||
- 在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,修复时光机逐日汇总逻辑:`metrics.marketValue` 已使用 CNY 价格计算可直接取用,`metrics.totalInvested` 基于原始币种价格需乘以 `exchangeRate` 折算为 CNY,确保历史成本曲线正确。
|
||||
- 重新触发时光机清洗,成功重构 1247 天历史快照数据。
|
||||
|
||||
## 重构历史快照生成逻辑,消除新旧算法断层 (Task 57)
|
||||
- 将时光机重构逻辑全面接入 finance utils 引擎,清洗历史脏快照,消除新旧算法迭代导致的本金曲线断层。
|
||||
- 在 `src/utils/finance.ts` 的 `calculateAssetMetrics` 返回值中新增 `totalInvested` 字段,直接输出真实投入本金(含手续费),避免通过 `marketValue - accumulatedPnl` 间接推导导致的精度损失。
|
||||
- 在 `src/actions/snapshots.ts` 中废弃 `reconstructPortfolioHistory()` 的旧版 Day-by-Day 加减法逻辑,改为:对每一天 `currentDate`,获取该资产在 `currentDate` 及之前的所有交易流水 `historicalTx` 和历史收盘价 `historicalPrice`(断点结转),调用 `calculateAssetMetrics(historicalTx, historicalPrice)` 获取 `metrics.marketValue` 和 `metrics.totalInvested`,分别累加为当天的 `totalValueCny` 和 `totalCostCny`。
|
||||
- 重构后的 `reconstructPortfolioHistory()` 执行第一步调用 `db.delete(portfolioSnapshots)` 彻底清空旧的脏快照,然后从第一笔交易开始用新算法逐天重新生成,确保历史成本曲线平滑过渡、数值一致。
|
||||
|
||||
## 基础设施与底层架构
|
||||
- 完成根目录的 Next.js 初始化、基础依赖安装与环境变量配置。
|
||||
- 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。
|
||||
|
||||
13
scripts/reconstruct.ts
Normal file
13
scripts/reconstruct.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { reconstructPortfolioHistory } from '@/actions/snapshots';
|
||||
|
||||
async function main() {
|
||||
console.log('Starting reconstructPortfolioHistory...');
|
||||
const result = await reconstructPortfolioHistory();
|
||||
console.log('Result:', JSON.stringify(result, null, 2));
|
||||
console.log('Done.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -24,8 +24,12 @@ interface Position {
|
||||
realizedPnlCny: string;
|
||||
avgCost: string;
|
||||
dilutedCost: string;
|
||||
dilutedCostCny: string;
|
||||
floatingPnl: string;
|
||||
floatingPnlCny: string;
|
||||
accumulatedPnl: string;
|
||||
accumulatedPnlCny: string;
|
||||
marketValueCny: string;
|
||||
holdingDays: number;
|
||||
exchange: string;
|
||||
accumulatedDividendsCny: string;
|
||||
@ -326,6 +330,17 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
holding.latestPrice
|
||||
);
|
||||
|
||||
// 获取资产对人民币的汇率
|
||||
const fxRate = new Big(
|
||||
getRate(rateMap, holding.baseCurrency, 'CNY') || '1'
|
||||
);
|
||||
|
||||
// 将引擎返回的原生币种金额折算为 CNY
|
||||
const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();
|
||||
const floatingPnlCny = new Big(metrics.floatingPnl).times(fxRate).toString();
|
||||
const accumulatedPnlCny = new Big(metrics.accumulatedPnl).times(fxRate).toString();
|
||||
const dilutedCostCny = new Big(metrics.dilutedCost).times(fxRate).toString();
|
||||
|
||||
const holdingNative = new Big(metrics.holdings);
|
||||
const avgCostNative = new Big(metrics.averageCost);
|
||||
const dilutedCostNative = new Big(metrics.dilutedCost);
|
||||
@ -370,8 +385,12 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
realizedPnlCny: holding.realizedPnlCny.toString(),
|
||||
avgCost: metrics.averageCost,
|
||||
dilutedCost: metrics.dilutedCost,
|
||||
dilutedCostCny,
|
||||
floatingPnl: metrics.floatingPnl,
|
||||
floatingPnlCny,
|
||||
accumulatedPnl: metrics.accumulatedPnl,
|
||||
accumulatedPnlCny,
|
||||
marketValueCny,
|
||||
holdingDays,
|
||||
exchange: holding.exchange,
|
||||
accumulatedDividendsCny: holding.accumulatedDividendsCny.toString(),
|
||||
@ -397,28 +416,20 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
export async function getPortfolioSummary() {
|
||||
const positions = await getPortfolioPositions();
|
||||
|
||||
const totalCnyValue = positions.reduce(
|
||||
(sum, pos) => sum.plus(new Big(pos.cnyValue)),
|
||||
new Big('0')
|
||||
);
|
||||
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
||||
let totalCnyValue = new Big('0');
|
||||
let totalPnlCny = new Big('0');
|
||||
let totalFloatingPnlCny = new Big('0');
|
||||
|
||||
const totalPnlCny = positions.reduce(
|
||||
(sum, pos) => sum.plus(new Big(pos.pnlCny)),
|
||||
new Big('0')
|
||||
);
|
||||
|
||||
const unrealizedPnlCny = positions.reduce(
|
||||
(sum, pos) => {
|
||||
const totalPnl = new Big(pos.pnlCny);
|
||||
const realized = new Big(pos.realizedPnlCny);
|
||||
return sum.plus(totalPnl.minus(realized));
|
||||
},
|
||||
new Big('0')
|
||||
);
|
||||
for (const pos of positions) {
|
||||
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
||||
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
|
||||
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
|
||||
}
|
||||
|
||||
const chartData = positions.map((pos, index) => ({
|
||||
name: pos.symbol,
|
||||
value: new Big(pos.cnyValue).toNumber(),
|
||||
value: new Big(pos.marketValueCny || '0').toNumber(),
|
||||
fill: [
|
||||
'#3b82f6',
|
||||
'#8b5cf6',
|
||||
@ -439,11 +450,11 @@ export async function getPortfolioSummary() {
|
||||
const market = getMarketFromExchange(pos.exchange);
|
||||
const existing = marketMap.get(market);
|
||||
if (existing) {
|
||||
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.cnyValue));
|
||||
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
||||
} else {
|
||||
marketMap.set(market, {
|
||||
market,
|
||||
totalCnyValue: new Big(pos.cnyValue),
|
||||
totalCnyValue: new Big(pos.marketValueCny || '0'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -473,7 +484,7 @@ export async function getPortfolioSummary() {
|
||||
positions,
|
||||
totalCnyValue: totalCnyValue.toString(),
|
||||
totalPnlCny: totalPnlCny.toString(),
|
||||
unrealizedPnlCny: unrealizedPnlCny.toString(),
|
||||
unrealizedPnlCny: totalFloatingPnlCny.toString(),
|
||||
chartData,
|
||||
marketAllocation,
|
||||
};
|
||||
|
||||
@ -5,6 +5,7 @@ import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeR
|
||||
import { getPortfolioPositions } from './portfolio';
|
||||
import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
|
||||
import Big from 'big.js';
|
||||
import { calculateAssetMetrics } from '@/utils/finance';
|
||||
|
||||
function formatDateString(date: Date): string {
|
||||
const yyyy = date.getFullYear();
|
||||
@ -25,13 +26,19 @@ function getTodayInShanghai(): string {
|
||||
export async function recordDailySnapshot() {
|
||||
const positions = await getPortfolioPositions();
|
||||
|
||||
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||
const totalValueCny = positions.reduce(
|
||||
(sum, pos) => sum.plus(pos.cnyValue || '0'),
|
||||
(sum, pos) => sum.plus(new Big(pos.marketValueCny || '0')),
|
||||
new Big(0)
|
||||
).toString();
|
||||
|
||||
// 推导真实投入本金 CNY = 市值 - 累计盈亏
|
||||
const totalCostCny = positions.reduce(
|
||||
(sum, pos) => sum.plus(pos.totalCostCny || '0'),
|
||||
(sum, pos) => {
|
||||
const mv = new Big(pos.marketValueCny || '0');
|
||||
const ap = new Big(pos.accumulatedPnlCny || '0');
|
||||
return sum.plus(mv.minus(ap));
|
||||
},
|
||||
new Big(0)
|
||||
).toString();
|
||||
|
||||
@ -282,35 +289,61 @@ export async function reconstructPortfolioHistory() {
|
||||
return price;
|
||||
}
|
||||
|
||||
await db.delete(portfolioSnapshots);
|
||||
|
||||
let daysReconstructed = 0;
|
||||
|
||||
while (formatDateString(currentDate) <= todayStr) {
|
||||
const dateStr = formatDateString(currentDate);
|
||||
|
||||
const positions = await getHistoricalPositions(currentDate);
|
||||
const historicalTx = await db
|
||||
.select({
|
||||
assetId: transactions.assetId,
|
||||
executedAt: transactions.executedAt,
|
||||
txType: transactions.txType,
|
||||
quantity: transactions.quantity,
|
||||
price: transactions.price,
|
||||
fee: transactions.fee,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(lte(transactions.executedAt, currentDate))
|
||||
.orderBy(asc(transactions.executedAt));
|
||||
|
||||
let totalValueCny = new Big('0');
|
||||
let totalCostCny = new Big('0');
|
||||
|
||||
for (const pos of positions) {
|
||||
const priceStr = await getEffectivePrice(pos.assetId, currentDate);
|
||||
const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD';
|
||||
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
||||
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
||||
).map(t => t.assetId))];
|
||||
|
||||
if (!priceStr) {
|
||||
const fallbackPrice = assetLatestPriceMap.get(pos.assetId) || '0';
|
||||
const cnyPrice = convertPriceToCny(fallbackPrice, baseCurrency);
|
||||
const price = new Big(cnyPrice);
|
||||
const qty = new Big(pos.quantity);
|
||||
totalValueCny = totalValueCny.plus(price.times(qty));
|
||||
totalCostCny = totalCostCny.plus(pos.totalCost);
|
||||
continue;
|
||||
}
|
||||
for (const assetId of uniqueAssetIds) {
|
||||
const assetTxs = historicalTx
|
||||
.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'))
|
||||
.map(t => ({
|
||||
date: new Date(t.executedAt).toISOString().split('T')[0],
|
||||
txType: t.txType,
|
||||
quantity: t.quantity.toString(),
|
||||
price: t.price.toString(),
|
||||
fee: t.fee.toString(),
|
||||
}));
|
||||
|
||||
const cnyPrice = convertPriceToCny(priceStr, baseCurrency);
|
||||
const price = new Big(cnyPrice);
|
||||
const qty = new Big(pos.quantity);
|
||||
totalValueCny = totalValueCny.plus(price.times(qty));
|
||||
totalCostCny = totalCostCny.plus(pos.totalCost);
|
||||
const priceStr = await getEffectivePrice(assetId, currentDate);
|
||||
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
||||
|
||||
const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
||||
|
||||
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||
|
||||
// 获取资产对人民币的汇率
|
||||
const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1');
|
||||
|
||||
// 统一汇率折算边界:calculateAssetMetrics 输出全部为 Base Currency,
|
||||
// 必须无例外地将所有金额字段乘以 fxRate 得到 CNY
|
||||
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
||||
const posCostCny = new Big(metrics.totalInvested).times(assetFxRate);
|
||||
|
||||
totalValueCny = totalValueCny.plus(posValueCny);
|
||||
totalCostCny = totalCostCny.plus(posCostCny);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
|
||||
@ -62,6 +62,7 @@ export function calculateAssetMetrics(transactions: TxRecord[], currentPrice: st
|
||||
dilutedCost: dilutedCost.toString(),
|
||||
floatingPnl: floatingPnl.toString(),
|
||||
accumulatedPnl: accumulatedPnl.toString(),
|
||||
marketValue: currentMarketValue.toString()
|
||||
marketValue: currentMarketValue.toString(),
|
||||
totalInvested: totalInvested.toString()
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user