refactor(ledger): 抛弃单一个股的 CNY 折算,全面重构基于 Native 原生币种的成本与盈亏算法
This commit is contained in:
parent
7d7a7804a6
commit
dd87eadbf4
@ -65,3 +65,12 @@
|
|||||||
- 升級了 Dashboard 資產卡片 UI,新增累計分紅展示,並優化了成本數據的格式化判斷邏輯。
|
- 升級了 Dashboard 資產卡片 UI,新增累計分紅展示,並優化了成本數據的格式化判斷邏輯。
|
||||||
- 修復了 `avgCostFormatted` 的判空邏輯,將 `Big.eq(0)` 修正為 `Big.eq('0')`,確保當 `pos.avgCost` 存在且不為 0 時能正確格式化,不再顯示 `¥-`。
|
- 修復了 `avgCostFormatted` 的判空邏輯,將 `Big.eq(0)` 修正為 `Big.eq('0')`,確保當 `pos.avgCost` 存在且不為 0 時能正確格式化,不再顯示 `¥-`。
|
||||||
- 在資產卡片中新增「累計分紅」行,展示 `accumulatedDividendsCny` 數據,保持與其它 CNY 數據一致的 `opacity-50` 樣式。
|
- 在資產卡片中新增「累計分紅」行,展示 `accumulatedDividendsCny` 數據,保持與其它 CNY 數據一致的 `opacity-50` 樣式。
|
||||||
|
|
||||||
|
## 持倉引擎 Native 幣種算法重構 (Task 38)
|
||||||
|
- 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
|
||||||
|
- 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。
|
||||||
|
- 新增 Native 成本指標:`totalBuyCostNative` (總買入成本)、`realizedPnlNative` (已實現盈虧)、`accumulatedDividendsNative` (累計分紅)。
|
||||||
|
- 新增 Native 成本均價:`avgCostNative = totalBuyCostNative / totalBuyQuantity`、`dilutedCostNative = (totalBuyCostNative - realizedPnlNative - accumulatedDividendsNative) / currentQuantity`。
|
||||||
|
- 新增浮動盈虧指標:`marketValueNative = latestPrice * currentQuantity`、`floatingPnlNative = marketValueNative - (avgCostNative * currentQuantity)`、`floatingPnlPercent = floatingPnlNative / (avgCostNative * currentQuantity) * 100`。
|
||||||
|
- 新增累計盈虧指標:`cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNative`、`cumulativePnlPercent = cumulativePnlNative / totalBuyCostNative * 100`。
|
||||||
|
- SELL 交易的已實現盈虧計算從 CNY 基準改為 Native 基準,CNY 計算保留用於前端兼容展示。
|
||||||
@ -27,6 +27,16 @@ interface Position {
|
|||||||
exchange: string;
|
exchange: string;
|
||||||
accumulatedDividendsCny: string;
|
accumulatedDividendsCny: string;
|
||||||
accumulatedDividendsNative: string;
|
accumulatedDividendsNative: string;
|
||||||
|
// Native 原生币种盈亏指标
|
||||||
|
totalBuyCostNative: string;
|
||||||
|
realizedPnlNative: string;
|
||||||
|
avgCostNative: string;
|
||||||
|
dilutedCostNative: string;
|
||||||
|
marketValueNative: string;
|
||||||
|
floatingPnlNative: string;
|
||||||
|
floatingPnlPercent: string;
|
||||||
|
cumulativePnlNative: string;
|
||||||
|
cumulativePnlPercent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawRate {
|
interface RawRate {
|
||||||
@ -159,6 +169,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
totalBuyQuantity: Big;
|
totalBuyQuantity: Big;
|
||||||
// 已实现盈亏
|
// 已实现盈亏
|
||||||
realizedPnlCny: Big;
|
realizedPnlCny: Big;
|
||||||
|
realizedPnlNative: Big;
|
||||||
accumulatedDividendsCny: Big;
|
accumulatedDividendsCny: Big;
|
||||||
accumulatedDividendsNative: Big;
|
accumulatedDividendsNative: Big;
|
||||||
// 首次买入日期
|
// 首次买入日期
|
||||||
@ -183,6 +194,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
totalBuyCostNative: new Big('0'),
|
totalBuyCostNative: new Big('0'),
|
||||||
totalBuyQuantity: new Big('0'),
|
totalBuyQuantity: new Big('0'),
|
||||||
realizedPnlCny: new Big('0'),
|
realizedPnlCny: new Big('0'),
|
||||||
|
realizedPnlNative: new Big('0'),
|
||||||
accumulatedDividendsCny: new Big('0'),
|
accumulatedDividendsCny: new Big('0'),
|
||||||
accumulatedDividendsNative: new Big('0'),
|
accumulatedDividendsNative: new Big('0'),
|
||||||
firstBuyDate: null,
|
firstBuyDate: null,
|
||||||
@ -211,14 +223,19 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
holding.firstBuyDate = new Date(tx.executedAt);
|
holding.firstBuyDate = new Date(tx.executedAt);
|
||||||
}
|
}
|
||||||
} else if (tx.txType === 'SELL') {
|
} else if (tx.txType === 'SELL') {
|
||||||
// 计算卖出时的平均成本
|
// 计算卖出时的平均成本 (Native)
|
||||||
let avgCostPerUnit = new Big('0');
|
let avgCostPerUnitNative = new Big('0');
|
||||||
if (holding.totalBuyQuantity.gt(0)) {
|
if (holding.totalBuyQuantity.gt(0)) {
|
||||||
avgCostPerUnit = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 * 汇率
|
// 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (Native)
|
||||||
const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price));
|
const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price));
|
||||||
|
const costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity));
|
||||||
|
const realizedPnlNative = sellRevenueNative.minus(costBasisNative);
|
||||||
|
holding.realizedPnlNative = holding.realizedPnlNative.plus(realizedPnlNative);
|
||||||
|
|
||||||
|
// 已实现盈亏 (CNY) 保留兼容
|
||||||
let appliedRate = tx.exchangeRate;
|
let appliedRate = tx.exchangeRate;
|
||||||
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
||||||
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
||||||
@ -226,10 +243,14 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
appliedRate = fallbackRate;
|
appliedRate = fallbackRate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sellRevenueCnyAdjusted = sellRevenueCny.times(new Big(appliedRate || '1'));
|
const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(appliedRate || '1'));
|
||||||
const costBasisCny = avgCostPerUnit.times(new Big(tx.quantity));
|
let avgCostPerUnitCny = new Big('0');
|
||||||
const realizedPnl = sellRevenueCnyAdjusted.minus(costBasisCny);
|
if (holding.totalBuyQuantity.gt(0)) {
|
||||||
holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnl);
|
avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
||||||
|
}
|
||||||
|
const costBasisCny = avgCostPerUnitCny.times(new Big(tx.quantity));
|
||||||
|
const realizedPnlCny = sellRevenueCny.minus(costBasisCny);
|
||||||
|
holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny);
|
||||||
|
|
||||||
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
|
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
|
||||||
} else if (tx.txType === 'AIRDROP') {
|
} else if (tx.txType === 'AIRDROP') {
|
||||||
@ -259,21 +280,53 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
rateMap
|
rateMap
|
||||||
);
|
);
|
||||||
|
|
||||||
// 未实现盈亏 = 当前市值 - 总买入成本
|
// 未实现盈亏 (CNY)
|
||||||
const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny);
|
const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny);
|
||||||
// 总盈亏 = 当前市值 - 总买入成本 + 已实现盈亏 + 累计分红
|
// 总盈亏 (CNY)
|
||||||
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
|
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
|
||||||
|
|
||||||
const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity);
|
// Native 原生币种计算
|
||||||
const pnlNative = currentNativeValue.minus(holding.totalBuyCostNative).plus(holding.accumulatedDividendsNative);
|
const marketValueNative = new Big(holding.latestPrice).times(holding.quantity);
|
||||||
|
const currentNativeValue = marketValueNative;
|
||||||
|
|
||||||
// 平均成本 = 总买入成本 / 总买入数量
|
// 平均成本 (Native) = 总买入成本 (Native) / 总买入数量
|
||||||
|
let avgCostNative = new Big('0');
|
||||||
|
if (holding.totalBuyQuantity.gt(0)) {
|
||||||
|
avgCostNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 摊薄成本 (Native) = (总买入成本 - 已实现盈亏 - 累计分红) / 当前持仓数量
|
||||||
|
let dilutedCostNative = new Big('0');
|
||||||
|
if (holding.quantity.gt(0)) {
|
||||||
|
dilutedCostNative = holding.totalBuyCostNative.minus(holding.realizedPnlNative).minus(holding.accumulatedDividendsNative).div(holding.quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浮动盈亏 (Native) = 市值 - (平均成本 * 当前持仓数量)
|
||||||
|
const floatingPnlNative = marketValueNative.minus(avgCostNative.times(holding.quantity));
|
||||||
|
|
||||||
|
// 浮动盈亏百分比 (Native)
|
||||||
|
let floatingPnlPercent = new Big('0');
|
||||||
|
const avgCostBasisNative = avgCostNative.times(holding.quantity);
|
||||||
|
if (avgCostBasisNative.gt(0)) {
|
||||||
|
floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 累计盈亏 (Native) = 浮动盈亏 + 已实现盈亏 + 累计分红
|
||||||
|
const cumulativePnlNative = floatingPnlNative.plus(holding.realizedPnlNative).plus(holding.accumulatedDividendsNative);
|
||||||
|
|
||||||
|
// 累计盈亏百分比 (Native) = 累计盈亏 / 总买入成本 * 100
|
||||||
|
let cumulativePnlPercent = new Big('0');
|
||||||
|
if (holding.totalBuyCostNative.gt(0)) {
|
||||||
|
cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平均成本 (CNY) 保留兼容
|
||||||
let avgCost = new Big('0');
|
let avgCost = new Big('0');
|
||||||
if (holding.totalBuyQuantity.gt(0)) {
|
if (holding.totalBuyQuantity.gt(0)) {
|
||||||
avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 摊薄成本 = (总买入成本 - 已实现盈亏 - 总分红) / 当前持仓数量
|
// 摊薄成本 (CNY) 保留兼容
|
||||||
let dilutedCost = new Big('0');
|
let dilutedCost = new Big('0');
|
||||||
if (holding.quantity.gt(0)) {
|
if (holding.quantity.gt(0)) {
|
||||||
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).div(holding.quantity);
|
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).div(holding.quantity);
|
||||||
@ -286,6 +339,9 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
holdingDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
holdingDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native 原生币种总盈亏 (保留兼容)
|
||||||
|
const pnlNative = marketValueNative.minus(holding.totalBuyCostNative).plus(holding.accumulatedDividendsNative);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
assetId: holding.assetId,
|
assetId: holding.assetId,
|
||||||
symbol: holding.symbol,
|
symbol: holding.symbol,
|
||||||
@ -304,9 +360,19 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
avgCost: avgCost.toString(),
|
avgCost: avgCost.toString(),
|
||||||
dilutedCost: dilutedCost.toString(),
|
dilutedCost: dilutedCost.toString(),
|
||||||
holdingDays,
|
holdingDays,
|
||||||
exchange: holding.exchange,
|
exchange: holding.exchange,
|
||||||
accumulatedDividendsCny: holding.accumulatedDividendsCny.toString(),
|
accumulatedDividendsCny: holding.accumulatedDividendsCny.toString(),
|
||||||
accumulatedDividendsNative: holding.accumulatedDividendsNative.toString(),
|
accumulatedDividendsNative: holding.accumulatedDividendsNative.toString(),
|
||||||
|
// Native 原生币种盈亏指标
|
||||||
|
totalBuyCostNative: holding.totalBuyCostNative.toString(),
|
||||||
|
realizedPnlNative: holding.realizedPnlNative.toString(),
|
||||||
|
avgCostNative: avgCostNative.toString(),
|
||||||
|
dilutedCostNative: dilutedCostNative.toString(),
|
||||||
|
marketValueNative: marketValueNative.toString(),
|
||||||
|
floatingPnlNative: floatingPnlNative.toString(),
|
||||||
|
floatingPnlPercent: floatingPnlPercent.toString(),
|
||||||
|
cumulativePnlNative: cumulativePnlNative.toString(),
|
||||||
|
cumulativePnlPercent: cumulativePnlPercent.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user