diff --git a/Memory.md b/Memory.md index ec340f5..6d09b17 100644 --- a/Memory.md +++ b/Memory.md @@ -64,4 +64,13 @@ ## 升級 Dashboard 資產卡片 UI (Task 36) - 升級了 Dashboard 資產卡片 UI,新增累計分紅展示,並優化了成本數據的格式化判斷邏輯。 - 修復了 `avgCostFormatted` 的判空邏輯,將 `Big.eq(0)` 修正為 `Big.eq('0')`,確保當 `pos.avgCost` 存在且不為 0 時能正確格式化,不再顯示 `¥-`。 -- 在資產卡片中新增「累計分紅」行,展示 `accumulatedDividendsCny` 數據,保持與其它 CNY 數據一致的 `opacity-50` 樣式。 \ No newline at end of file +- 在資產卡片中新增「累計分紅」行,展示 `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 計算保留用於前端兼容展示。 \ No newline at end of file diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 16c7d99..a9298d6 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -27,6 +27,16 @@ interface Position { exchange: string; accumulatedDividendsCny: string; accumulatedDividendsNative: string; + // Native 原生币种盈亏指标 + totalBuyCostNative: string; + realizedPnlNative: string; + avgCostNative: string; + dilutedCostNative: string; + marketValueNative: string; + floatingPnlNative: string; + floatingPnlPercent: string; + cumulativePnlNative: string; + cumulativePnlPercent: string; } interface RawRate { @@ -159,6 +169,7 @@ export async function getPortfolioPositions(): Promise { totalBuyQuantity: Big; // 已实现盈亏 realizedPnlCny: Big; + realizedPnlNative: Big; accumulatedDividendsCny: Big; accumulatedDividendsNative: Big; // 首次买入日期 @@ -183,6 +194,7 @@ export async function getPortfolioPositions(): Promise { totalBuyCostNative: new Big('0'), totalBuyQuantity: new Big('0'), realizedPnlCny: new Big('0'), + realizedPnlNative: new Big('0'), accumulatedDividendsCny: new Big('0'), accumulatedDividendsNative: new Big('0'), firstBuyDate: null, @@ -211,14 +223,19 @@ export async function getPortfolioPositions(): Promise { holding.firstBuyDate = new Date(tx.executedAt); } } else if (tx.txType === 'SELL') { - // 计算卖出时的平均成本 - let avgCostPerUnit = new Big('0'); + // 计算卖出时的平均成本 (Native) + let avgCostPerUnitNative = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { - avgCostPerUnit = holding.totalBuyCostCny.div(holding.totalBuyQuantity); + avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity); } - // 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 * 汇率 - const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)); + // 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (Native) + 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; if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY'); @@ -226,10 +243,14 @@ export async function getPortfolioPositions(): Promise { appliedRate = fallbackRate; } } - const sellRevenueCnyAdjusted = sellRevenueCny.times(new Big(appliedRate || '1')); - const costBasisCny = avgCostPerUnit.times(new Big(tx.quantity)); - const realizedPnl = sellRevenueCnyAdjusted.minus(costBasisCny); - holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnl); + const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(appliedRate || '1')); + let avgCostPerUnitCny = new Big('0'); + if (holding.totalBuyQuantity.gt(0)) { + 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)); } else if (tx.txType === 'AIRDROP') { @@ -259,21 +280,53 @@ export async function getPortfolioPositions(): Promise { rateMap ); - // 未实现盈亏 = 当前市值 - 总买入成本 + // 未实现盈亏 (CNY) const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny); - // 总盈亏 = 当前市值 - 总买入成本 + 已实现盈亏 + 累计分红 + // 总盈亏 (CNY) const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny); - const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity); - const pnlNative = currentNativeValue.minus(holding.totalBuyCostNative).plus(holding.accumulatedDividendsNative); + // Native 原生币种计算 + 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'); if (holding.totalBuyQuantity.gt(0)) { avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity); } - // 摊薄成本 = (总买入成本 - 已实现盈亏 - 总分红) / 当前持仓数量 + // 摊薄成本 (CNY) 保留兼容 let dilutedCost = new Big('0'); if (holding.quantity.gt(0)) { dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).div(holding.quantity); @@ -286,6 +339,9 @@ export async function getPortfolioPositions(): Promise { holdingDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); } + // Native 原生币种总盈亏 (保留兼容) + const pnlNative = marketValueNative.minus(holding.totalBuyCostNative).plus(holding.accumulatedDividendsNative); + result.push({ assetId: holding.assetId, symbol: holding.symbol, @@ -304,9 +360,19 @@ export async function getPortfolioPositions(): Promise { avgCost: avgCost.toString(), dilutedCost: dilutedCost.toString(), holdingDays, - exchange: holding.exchange, + exchange: holding.exchange, accumulatedDividendsCny: holding.accumulatedDividendsCny.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(), }); }