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,
};
}