feat(ledger): 重构盈亏计算引擎,实装摊薄/平均成本双重指标与持仓天数统计
This commit is contained in:
parent
bf57002313
commit
e093b94157
@ -36,3 +36,10 @@
|
||||
|
||||
## 修复记录
|
||||
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 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)』。
|
||||
- 持仓卡片新增『平均成本』、『摊薄成本』和『持仓天数』展示。
|
||||
@ -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)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">总盈亏:</span>
|
||||
<span className={`text-lg font-semibold ${pnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{pnlIsPositive ? '+' : ''}{formattedPnl}
|
||||
</span>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">持仓盈亏:</span>
|
||||
<span className={`text-lg font-semibold ${unrealizedIsPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{unrealizedIsPositive ? '+' : ''}{formattedUnrealized}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">总盈亏:</span>
|
||||
<span className={`text-lg font-semibold ${totalPnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{totalPnlIsPositive ? '+' : ''}{formattedTotalPnl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -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 (
|
||||
<Card key={pos.assetId}>
|
||||
<CardHeader>
|
||||
@ -97,24 +113,36 @@ export default async function DashboardPage() {
|
||||
<span className="text-muted-foreground">结算币种</span>
|
||||
<span className="font-medium">{pos.baseCurrency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">平均成本</span>
|
||||
<span className="font-medium">
|
||||
¥{avgCostFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">摊薄成本</span>
|
||||
<span className="font-medium">
|
||||
¥{dilutedCostFormatted}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">持仓天数</span>
|
||||
<span className="font-medium">
|
||||
{pos.holdingDays} 天
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">持仓成本 ({pos.baseCurrency})</span>
|
||||
<span className="font-medium">
|
||||
{formatAmount(pos.totalCostNative)}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const posPnlNative = new Big(pos.pnlNative);
|
||||
const posPnlNativePositive = posPnlNative.gte(0);
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">当前盈亏 ({pos.baseCurrency})</span>
|
||||
<span className={`font-semibold ${posPnlNativePositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">当前盈亏 ({pos.baseCurrency})</span>
|
||||
<span className={`font-semibold ${posPnlNativePositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{posPnlNativePositive ? '+' : ''}{formatAmount(pos.pnlNative)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between opacity-50">
|
||||
<span className="text-muted-foreground">成本 (CNY)</span>
|
||||
<span className="font-medium">
|
||||
@ -124,7 +152,7 @@ export default async function DashboardPage() {
|
||||
<div className="flex justify-between opacity-50">
|
||||
<span className="text-muted-foreground">盈亏 (CNY)</span>
|
||||
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{posPnlPositive ? '+' : ''}{formatAmount(pos.pnlCny)}
|
||||
{posPnlPositive ? '+' : ''}{formattedPosPnl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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<Position[]> {
|
||||
const allTransactions = await db
|
||||
const allTransactions = await db
|
||||
.select({
|
||||
txType: transactions.txType,
|
||||
quantity: transactions.quantity,
|
||||
@ -87,10 +102,11 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
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<Position[]> {
|
||||
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<Position[]> {
|
||||
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<Position[]> {
|
||||
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<Position[]> {
|
||||
}
|
||||
}
|
||||
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<Position[]> {
|
||||
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<Position[]> {
|
||||
}
|
||||
}
|
||||
|
||||
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<Position[]> {
|
||||
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<Position[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user