diff --git a/Memory.md b/Memory.md index c5b8b0e..f2a0ddc 100644 --- a/Memory.md +++ b/Memory.md @@ -35,4 +35,11 @@ - 重构 `` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。 ## 修复记录 -- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions)均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑,避免 `Invalid time value` 错误。 \ No newline at end of file +- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions)均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑,避免 `Invalid time value` 错误。 + +## 盈亏引擎重构 (Task 31) +- 重构盈亏计算引擎,支持已实现盈亏统计:交易按时间正序处理,SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`。 +- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。 +- 新增持仓天数统计:`holdingDays = today - 第一次 BUY 的日期`(基于上海时区)。 +- Dashboard 首页总览区分展示『持仓盈亏 (Unrealized P&L)』和『总盈亏 (Total P&L)』。 +- 持仓卡片新增『平均成本』、『摊薄成本』和『持仓天数』展示。 \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index da0839b..cf1b801 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -15,11 +15,13 @@ const CHART_COLORS = [ ]; export default async function DashboardPage() { - const { positions, totalCnyValue, chartData, totalPnlCny } = await getPortfolioSummary(); + const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny } = await getPortfolioSummary(); const formattedTotal = formatAmount(totalCnyValue); - const formattedPnl = formatAmount(totalPnlCny); - const pnlIsPositive = new Big(totalPnlCny).gte(0); + const formattedTotalPnl = formatAmount(totalPnlCny); + const formattedUnrealized = formatAmount(unrealizedPnlCny); + const totalPnlIsPositive = new Big(totalPnlCny).gte(0); + const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0); const displayChartData = chartData.map((item) => ({ ...item, @@ -50,11 +52,19 @@ export default async function DashboardPage() { 总资产 (CNY) -
- 总盈亏: - - {pnlIsPositive ? '+' : ''}{formattedPnl} - +
+
+ 持仓盈亏: + + {unrealizedIsPositive ? '+' : ''}{formattedUnrealized} + +
+
+ 总盈亏: + + {totalPnlIsPositive ? '+' : ''}{formattedTotalPnl} + +
@@ -72,6 +82,12 @@ export default async function DashboardPage() { const posPnlPositive = posPnl.gte(0); const formattedPosPnl = formatAmount(pos.pnlCny); + const posPnlNative = new Big(pos.pnlNative); + const posPnlNativePositive = posPnlNative.gte(0); + + const avgCostFormatted = new Big(pos.avgCost).gt(0) ? formatAmount(pos.avgCost) : '-'; + const dilutedCostFormatted = new Big(pos.dilutedCost).gt(0) ? formatAmount(pos.dilutedCost) : '-'; + return ( @@ -97,24 +113,36 @@ export default async function DashboardPage() { 结算币种 {pos.baseCurrency}
+
+ 平均成本 + + ¥{avgCostFormatted} + +
+
+ 摊薄成本 + + ¥{dilutedCostFormatted} + +
+
+ 持仓天数 + + {pos.holdingDays} 天 + +
持仓成本 ({pos.baseCurrency}) {formatAmount(pos.totalCostNative)}
- {(() => { - const posPnlNative = new Big(pos.pnlNative); - const posPnlNativePositive = posPnlNative.gte(0); - return ( -
- 当前盈亏 ({pos.baseCurrency}) - - {posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)} - -
- ); - })()} +
+ 当前盈亏 ({pos.baseCurrency}) + + {posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)} + +
成本 (CNY) @@ -124,7 +152,7 @@ export default async function DashboardPage() {
盈亏 (CNY) - {posPnlPositive ? '+' : ''}{formatAmount(pos.pnlCny)} + {posPnlPositive ? '+' : ''}{formattedPosPnl}
diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts index 53d2395..0789612 100644 --- a/src/actions/portfolio.ts +++ b/src/actions/portfolio.ts @@ -3,7 +3,7 @@ import { db } from '@/db'; import { transactions, assets, exchangeRates } from '@/db/schema'; import Big from 'big.js'; -import { desc, eq } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; interface Position { assetId: string; @@ -17,6 +17,13 @@ interface Position { pnlCny: string; totalCostNative: string; pnlNative: string; + // 新增:双重成本与盈亏指标 + totalBuyCost: string; + totalBuyQuantity: string; + realizedPnlCny: string; + avgCost: string; + dilutedCost: string; + holdingDays: number; } interface RawRate { @@ -73,8 +80,16 @@ function calculateCnyValueFromPrice( return new Big('0'); } +function getTodayInShanghai(): Date { + const now = new Date(); + const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' }); + const utcDate = new Date(utcStr); + const shanghaiOffset = 8 * 60 * 60 * 1000; + return new Date(utcDate.getTime() + shanghaiOffset); +} + export async function getPortfolioPositions(): Promise { - const allTransactions = await db + const allTransactions = await db .select({ txType: transactions.txType, quantity: transactions.quantity, @@ -87,10 +102,11 @@ export async function getPortfolioPositions(): Promise { assetType: assets.type, assetBaseCurrency: assets.baseCurrency, assetLatestPrice: assets.latestPrice, + executedAt: transactions.executedAt, }) .from(transactions) .leftJoin(assets, eq(assets.id, transactions.assetId)) - .orderBy(desc(transactions.executedAt)); + .orderBy(asc(transactions.executedAt)); const rates = await db.select({ fromCurrency: exchangeRates.fromCurrency, @@ -108,8 +124,14 @@ export async function getPortfolioPositions(): Promise { quantity: Big; baseCurrency: string; latestPrice: string; - totalCostCny: Big; - totalCostNative: Big; + // 累计买入指标 + totalBuyCostCny: Big; + totalBuyCostNative: Big; + totalBuyQuantity: Big; + // 已实现盈亏 + realizedPnlCny: Big; + // 首次买入日期 + firstBuyDate: Date | null; }>(); for (const tx of allTransactions) { @@ -125,8 +147,11 @@ export async function getPortfolioPositions(): Promise { quantity: new Big('0'), baseCurrency: tx.assetBaseCurrency || '', latestPrice: tx.assetLatestPrice || '0', - totalCostCny: new Big('0'), - totalCostNative: new Big('0'), + totalBuyCostCny: new Big('0'), + totalBuyCostNative: new Big('0'), + totalBuyQuantity: new Big('0'), + realizedPnlCny: new Big('0'), + firstBuyDate: null, }); } @@ -135,7 +160,7 @@ export async function getPortfolioPositions(): Promise { if (tx.txType === 'BUY') { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); const costPerUnit = new Big(tx.quantity).times(new Big(tx.price)); - holding.totalCostNative = holding.totalCostNative.plus(costPerUnit); + holding.totalBuyCostNative = holding.totalBuyCostNative.plus(costPerUnit); let appliedRate = tx.exchangeRate; if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY'); @@ -144,11 +169,21 @@ export async function getPortfolioPositions(): Promise { } } const costCny = costPerUnit.times(new Big(appliedRate || '1')); - holding.totalCostCny = holding.totalCostCny.plus(costCny); + holding.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny); + + // 记录首次买入日期 + if (!holding.firstBuyDate && tx.executedAt) { + holding.firstBuyDate = new Date(tx.executedAt); + } } else if (tx.txType === 'SELL') { - holding.quantity = holding.quantity.minus(new Big(tx.quantity)); - const sellCostPerUnit = new Big(tx.quantity).times(new Big(tx.price)); - holding.totalCostNative = holding.totalCostNative.minus(sellCostPerUnit); + // 计算卖出时的平均成本 + let avgCostPerUnit = new Big('0'); + if (holding.totalBuyQuantity.gt(0)) { + avgCostPerUnit = holding.totalBuyCostCny.div(holding.totalBuyQuantity); + } + + // 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 * 汇率 + const sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)); let appliedRate = tx.exchangeRate; if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') { const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY'); @@ -156,8 +191,12 @@ export async function getPortfolioPositions(): Promise { appliedRate = fallbackRate; } } - const sellCostCny = sellCostPerUnit.times(new Big(appliedRate || '1')); - holding.totalCostCny = holding.totalCostCny.minus(sellCostCny); + 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); + + holding.quantity = holding.quantity.minus(new Big(tx.quantity)); } else if (tx.txType === 'AIRDROP') { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); } else if (tx.txType === 'DIVIDEND') { @@ -169,9 +208,8 @@ export async function getPortfolioPositions(): Promise { } } + const today = getTodayInShanghai(); const result: Position[] = []; - let totalCnyValue = new Big('0'); - let totalPnlCny = new Big('0'); for (const [_, holding] of holdings) { if (holding.quantity.lte(0)) continue; @@ -183,13 +221,32 @@ export async function getPortfolioPositions(): Promise { rateMap ); - totalCnyValue = totalCnyValue.plus(cnyValue); - - const pnlCny = cnyValue.minus(holding.totalCostCny); - totalPnlCny = totalPnlCny.plus(pnlCny); + // 未实现盈亏 = 当前市值 - 总买入成本 + const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny); + // 总盈亏 = 未实现盈亏 + 已实现盈亏 + const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny); const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity); - const pnlNative = currentNativeValue.minus(holding.totalCostNative); + const pnlNative = currentNativeValue.minus(holding.totalBuyCostNative); + + // 平均成本 = 总买入成本 / 总买入数量 + let avgCost = new Big('0'); + if (holding.totalBuyQuantity.gt(0)) { + avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity); + } + + // 摊薄成本 = (总买入成本 - 已实现盈亏) / 当前持仓数量 + let dilutedCost = new Big('0'); + if (holding.quantity.gt(0)) { + dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).div(holding.quantity); + } + + // 持仓天数 + let holdingDays = 0; + if (holding.firstBuyDate) { + const diffMs = today.getTime() - holding.firstBuyDate.getTime(); + holdingDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); + } result.push({ assetId: holding.assetId, @@ -199,10 +256,16 @@ export async function getPortfolioPositions(): Promise { quantity: holding.quantity.toString(), baseCurrency: holding.baseCurrency, cnyValue: cnyValue.toString(), - totalCostCny: holding.totalCostCny.toString(), - pnlCny: pnlCny.toString(), - totalCostNative: holding.totalCostNative.toString(), + totalCostCny: holding.totalBuyCostCny.toString(), + pnlCny: totalPnlCny.toString(), + totalCostNative: holding.totalBuyCostNative.toString(), pnlNative: pnlNative.toString(), + totalBuyCost: holding.totalBuyCostCny.toString(), + totalBuyQuantity: holding.totalBuyQuantity.toString(), + realizedPnlCny: holding.realizedPnlCny.toString(), + avgCost: avgCost.toString(), + dilutedCost: dilutedCost.toString(), + holdingDays, }); } @@ -222,6 +285,15 @@ export async function getPortfolioSummary() { 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') + ); + const chartData = positions.map((pos, index) => ({ name: pos.symbol, value: new Big(pos.cnyValue), @@ -239,6 +311,7 @@ export async function getPortfolioSummary() { positions, totalCnyValue: totalCnyValue.toString(), totalPnlCny: totalPnlCny.toString(), + unrealizedPnlCny: unrealizedPnlCny.toString(), chartData, }; }