fix(ledger): 修复汇率转换的双标幻觉,统一大盘与快照的财务聚合基准
This commit is contained in:
parent
52a94a9ffa
commit
b4f21e7cd6
@ -1,5 +1,12 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# 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)
|
## 全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58)
|
||||||
- 修复了跨币种资产直接相加导致的盈亏总额失真问题:USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算,HKD 亏损同理。
|
- 修复了跨币种资产直接相加导致的盈亏总额失真问题:USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算,HKD 亏损同理。
|
||||||
- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 中,对每个资产获取 `exchangeRate`(Base Currency → CNY),将财务引擎 (`calculateAssetMetrics`) 产出的所有绝对金额字段(`marketValue`、`floatingPnl`、`accumulatedPnl`、`dilutedCost`)乘以汇率,映射为 `Cny` 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。
|
- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 中,对每个资产获取 `exchangeRate`(Base Currency → CNY),将财务引擎 (`calculateAssetMetrics`) 产出的所有绝对金额字段(`marketValue`、`floatingPnl`、`accumulatedPnl`、`dilutedCost`)乘以汇率,映射为 `Cny` 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@ -416,28 +416,20 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
export async function getPortfolioSummary() {
|
export async function getPortfolioSummary() {
|
||||||
const positions = await getPortfolioPositions();
|
const positions = await getPortfolioPositions();
|
||||||
|
|
||||||
const totalCnyValue = positions.reduce(
|
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
||||||
(sum, pos) => sum.plus(new Big(pos.cnyValue)),
|
let totalCnyValue = new Big('0');
|
||||||
new Big('0')
|
let totalPnlCny = new Big('0');
|
||||||
);
|
let totalFloatingPnlCny = new Big('0');
|
||||||
|
|
||||||
const totalPnlCny = positions.reduce(
|
for (const pos of positions) {
|
||||||
(sum, pos) => sum.plus(new Big(pos.pnlCny)),
|
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
||||||
new Big('0')
|
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
|
||||||
);
|
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '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')
|
|
||||||
);
|
|
||||||
|
|
||||||
const chartData = positions.map((pos, index) => ({
|
const chartData = positions.map((pos, index) => ({
|
||||||
name: pos.symbol,
|
name: pos.symbol,
|
||||||
value: new Big(pos.cnyValue).toNumber(),
|
value: new Big(pos.marketValueCny || '0').toNumber(),
|
||||||
fill: [
|
fill: [
|
||||||
'#3b82f6',
|
'#3b82f6',
|
||||||
'#8b5cf6',
|
'#8b5cf6',
|
||||||
@ -458,11 +450,11 @@ export async function getPortfolioSummary() {
|
|||||||
const market = getMarketFromExchange(pos.exchange);
|
const market = getMarketFromExchange(pos.exchange);
|
||||||
const existing = marketMap.get(market);
|
const existing = marketMap.get(market);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.cnyValue));
|
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
||||||
} else {
|
} else {
|
||||||
marketMap.set(market, {
|
marketMap.set(market, {
|
||||||
market,
|
market,
|
||||||
totalCnyValue: new Big(pos.cnyValue),
|
totalCnyValue: new Big(pos.marketValueCny || '0'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -492,7 +484,7 @@ export async function getPortfolioSummary() {
|
|||||||
positions,
|
positions,
|
||||||
totalCnyValue: totalCnyValue.toString(),
|
totalCnyValue: totalCnyValue.toString(),
|
||||||
totalPnlCny: totalPnlCny.toString(),
|
totalPnlCny: totalPnlCny.toString(),
|
||||||
unrealizedPnlCny: unrealizedPnlCny.toString(),
|
unrealizedPnlCny: totalFloatingPnlCny.toString(),
|
||||||
chartData,
|
chartData,
|
||||||
marketAllocation,
|
marketAllocation,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -26,13 +26,19 @@ function getTodayInShanghai(): string {
|
|||||||
export async function recordDailySnapshot() {
|
export async function recordDailySnapshot() {
|
||||||
const positions = await getPortfolioPositions();
|
const positions = await getPortfolioPositions();
|
||||||
|
|
||||||
|
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||||
const totalValueCny = positions.reduce(
|
const totalValueCny = positions.reduce(
|
||||||
(sum, pos) => sum.plus(pos.cnyValue || '0'),
|
(sum, pos) => sum.plus(new Big(pos.marketValueCny || '0')),
|
||||||
new Big(0)
|
new Big(0)
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
|
// 推导真实投入本金 CNY = 市值 - 累计盈亏
|
||||||
const totalCostCny = positions.reduce(
|
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)
|
new Big(0)
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
@ -324,21 +330,16 @@ export async function reconstructPortfolioHistory() {
|
|||||||
const priceStr = await getEffectivePrice(assetId, currentDate);
|
const priceStr = await getEffectivePrice(assetId, currentDate);
|
||||||
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
||||||
|
|
||||||
let cnyPrice: string;
|
const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
||||||
if (!priceStr) {
|
|
||||||
cnyPrice = convertPriceToCny(assetLatestPriceMap.get(assetId) || '0', baseCurrency);
|
|
||||||
} else {
|
|
||||||
cnyPrice = convertPriceToCny(priceStr, baseCurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = calculateAssetMetrics(assetTxs, cnyPrice);
|
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||||
|
|
||||||
// 获取资产对人民币的汇率
|
// 获取资产对人民币的汇率
|
||||||
const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1');
|
const assetFxRate = new Big(getRate(baseCurrency, 'CNY') || '1');
|
||||||
|
|
||||||
// marketValue 已使用 CNY 价格计算,直接取用
|
// 统一汇率折算边界:calculateAssetMetrics 输出全部为 Base Currency,
|
||||||
// totalInvested 基于原始币种价格,需乘以汇率折算为 CNY
|
// 必须无例外地将所有金额字段乘以 fxRate 得到 CNY
|
||||||
const posValueCny = new Big(metrics.marketValue);
|
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
||||||
const posCostCny = new Big(metrics.totalInvested).times(assetFxRate);
|
const posCostCny = new Big(metrics.totalInvested).times(assetFxRate);
|
||||||
|
|
||||||
totalValueCny = totalValueCny.plus(posValueCny);
|
totalValueCny = totalValueCny.plus(posValueCny);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user