feat(ledger): 重构盈亏计算引擎,实装摊薄/平均成本双重指标与持仓天数统计

This commit is contained in:
2026-04-28 16:39:22 +08:00
parent bf57002313
commit e093b94157
3 changed files with 154 additions and 46 deletions
+97 -24
View File
@@ -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,
};
}