521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
'use server';
|
|
|
|
import { db } from '@/db';
|
|
import { transactions, assets, exchangeRates, exchangeRatesHistory } from '@/db/schema';
|
|
import Big from 'big.js';
|
|
import { asc, desc, eq } from 'drizzle-orm';
|
|
import { calculateAssetMetrics } from '@/utils/finance';
|
|
|
|
interface Position {
|
|
assetId: string;
|
|
symbol: string;
|
|
name: string | null;
|
|
type: string;
|
|
quantity: string;
|
|
baseCurrency: string;
|
|
cnyValue: string;
|
|
totalCostCny: string;
|
|
pnlCny: string;
|
|
totalCostNative: string;
|
|
pnlNative: string;
|
|
// 新增:双重成本与盈亏指标
|
|
totalBuyCost: string;
|
|
totalBuyQuantity: string;
|
|
realizedPnlCny: string;
|
|
avgCost: string;
|
|
dilutedCost: string;
|
|
dilutedCostCny: string;
|
|
floatingPnl: string;
|
|
floatingPnlCny: string;
|
|
accumulatedPnl: string;
|
|
accumulatedPnlCny: string;
|
|
marketValueCny: string;
|
|
holdingDays: number;
|
|
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;
|
|
latestPrice: string;
|
|
transactions: TransactionRecord[];
|
|
}
|
|
|
|
interface TransactionRecord {
|
|
id: string;
|
|
txType: string;
|
|
quantity: string;
|
|
price: string;
|
|
fee: string;
|
|
txCurrency: string;
|
|
executedAt: Date | null;
|
|
}
|
|
|
|
interface RawRate {
|
|
fromCurrency: string;
|
|
toCurrency: string;
|
|
rate: string;
|
|
}
|
|
|
|
async function getLatestRatesMap(): Promise<Record<string, Big>> {
|
|
const usdResult = await db
|
|
.select({
|
|
rate: exchangeRatesHistory.rate,
|
|
})
|
|
.from(exchangeRatesHistory)
|
|
.where(eq(exchangeRatesHistory.fromCurrency, 'USD'))
|
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
|
.limit(1);
|
|
|
|
const hkdResult = await db
|
|
.select({
|
|
rate: exchangeRatesHistory.rate,
|
|
})
|
|
.from(exchangeRatesHistory)
|
|
.where(eq(exchangeRatesHistory.fromCurrency, 'HKD'))
|
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
|
.limit(1);
|
|
|
|
const dbUsd = usdResult[0];
|
|
const dbHkd = hkdResult[0];
|
|
|
|
return {
|
|
CNY: new Big(1),
|
|
USD: new Big(dbUsd?.rate || 7.2),
|
|
HKD: new Big(dbHkd?.rate || 0.9),
|
|
};
|
|
}
|
|
|
|
function buildRateMap(rates: RawRate[]): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
for (const r of rates) {
|
|
map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getRate(
|
|
rateMap: Map<string, string>,
|
|
from: string,
|
|
to: string
|
|
): string | null {
|
|
const direct = rateMap.get(`${from}_${to}`);
|
|
if (direct) return direct;
|
|
return null;
|
|
}
|
|
|
|
function calculateCnyValueFromPrice(
|
|
quantity: Big,
|
|
latestPrice: string,
|
|
baseCurrency: string,
|
|
rateMap: Map<string, string>
|
|
): Big {
|
|
const price = new Big(latestPrice || '0');
|
|
|
|
if (baseCurrency === 'CNY') {
|
|
return quantity.times(price);
|
|
}
|
|
|
|
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
|
if (directRate) {
|
|
return quantity.times(price).times(new Big(directRate));
|
|
}
|
|
|
|
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
|
if (!usdToCny) {
|
|
return new Big('0');
|
|
}
|
|
|
|
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
|
if (usdRate) {
|
|
return quantity.times(price).times(new Big(usdRate)).times(new Big(usdToCny));
|
|
}
|
|
|
|
return new Big('0');
|
|
}
|
|
|
|
function getMarketFromExchange(exchange: string): string {
|
|
if (!exchange) return '未知';
|
|
const upper = exchange.toUpperCase();
|
|
if (upper === 'SSE' || upper === 'SZSE') return 'A股';
|
|
if (upper === 'HKEX') return '港股';
|
|
if (upper === 'CRYPTO') return '虚拟币';
|
|
return '美股';
|
|
}
|
|
|
|
const MARKET_COLORS: Record<string, string> = {
|
|
'A股': '#ef4444',
|
|
'港股': '#f59e0b',
|
|
'美股': '#3b82f6',
|
|
'虚拟币': '#10b981',
|
|
};
|
|
|
|
interface MarketAllocation {
|
|
market: string;
|
|
name: string;
|
|
totalCnyValue: number;
|
|
percentage: number;
|
|
fill: string;
|
|
}
|
|
|
|
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
|
|
.select({
|
|
id: transactions.id,
|
|
txType: transactions.txType,
|
|
quantity: transactions.quantity,
|
|
price: transactions.price,
|
|
fee: transactions.fee,
|
|
exchangeRate: transactions.exchangeRate,
|
|
txCurrency: transactions.txCurrency,
|
|
assetId: transactions.assetId,
|
|
assetSymbol: assets.symbol,
|
|
assetName: assets.name,
|
|
assetType: assets.type,
|
|
assetBaseCurrency: assets.baseCurrency,
|
|
assetLatestPrice: assets.latestPrice,
|
|
assetExchange: assets.exchange,
|
|
executedAt: transactions.executedAt,
|
|
})
|
|
.from(transactions)
|
|
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
|
.orderBy(asc(transactions.executedAt));
|
|
|
|
const dynamicRateMap = await getLatestRatesMap();
|
|
|
|
const rateMap = new Map<string, string>();
|
|
for (const [currency, rate] of Object.entries(dynamicRateMap)) {
|
|
if (currency !== 'CNY') {
|
|
rateMap.set(`${currency}_CNY`, rate.toString());
|
|
}
|
|
}
|
|
|
|
const holdings = new Map<string, {
|
|
assetId: string;
|
|
symbol: string;
|
|
name: string | null;
|
|
type: string;
|
|
quantity: Big;
|
|
baseCurrency: string;
|
|
latestPrice: string;
|
|
exchange: string;
|
|
// 累计买入指标
|
|
totalBuyCostCny: Big;
|
|
totalBuyCostNative: Big;
|
|
totalBuyQuantity: Big;
|
|
// 已实现盈亏
|
|
realizedPnlCny: Big;
|
|
realizedPnlNative: Big;
|
|
accumulatedDividendsCny: Big;
|
|
accumulatedDividendsNative: Big;
|
|
// 首次买入日期
|
|
firstBuyDate: Date | null;
|
|
// 原始流水明细
|
|
transactions: TransactionRecord[];
|
|
}>();
|
|
|
|
for (const tx of allTransactions) {
|
|
if (!tx.assetId) continue;
|
|
|
|
const existing = holdings.get(tx.assetId);
|
|
if (!existing) {
|
|
holdings.set(tx.assetId, {
|
|
assetId: tx.assetId,
|
|
symbol: tx.assetSymbol || tx.assetId,
|
|
name: tx.assetName,
|
|
type: tx.assetType || 'CASH',
|
|
quantity: new Big('0'),
|
|
baseCurrency: tx.assetBaseCurrency || '',
|
|
latestPrice: tx.assetLatestPrice || '0',
|
|
exchange: tx.assetExchange || 'US',
|
|
totalBuyCostCny: new Big('0'),
|
|
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,
|
|
transactions: [],
|
|
});
|
|
}
|
|
|
|
const holding = holdings.get(tx.assetId)!;
|
|
|
|
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.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');
|
|
if (fallbackRate) {
|
|
appliedRate = fallbackRate;
|
|
}
|
|
}
|
|
const costCny = costPerUnit.times(new Big(appliedRate || '1'));
|
|
holding.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny);
|
|
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity));
|
|
|
|
// 记录首次买入日期
|
|
if (!holding.firstBuyDate && tx.executedAt) {
|
|
holding.firstBuyDate = new Date(tx.executedAt);
|
|
}
|
|
} else if (tx.txType === 'SELL') {
|
|
// 计算卖出时的平均成本 (Native)
|
|
let avgCostPerUnitNative = new Big('0');
|
|
if (holding.totalBuyQuantity.gt(0)) {
|
|
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
|
|
}
|
|
|
|
// 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (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');
|
|
if (fallbackRate) {
|
|
appliedRate = fallbackRate;
|
|
}
|
|
}
|
|
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') {
|
|
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
|
} else if (tx.txType === 'DIVIDEND') {
|
|
const dividendAmountNative = new Big(tx.quantity).times(new Big(tx.price));
|
|
const dividendCny = dividendAmountNative.times(new Big(tx.exchangeRate || '1'));
|
|
holding.accumulatedDividendsCny = holding.accumulatedDividendsCny.plus(dividendCny);
|
|
holding.accumulatedDividendsNative = holding.accumulatedDividendsNative.plus(dividendAmountNative);
|
|
}
|
|
|
|
if (tx.assetLatestPrice) {
|
|
holding.latestPrice = tx.assetLatestPrice;
|
|
}
|
|
|
|
holding.transactions.push({
|
|
id: tx.id,
|
|
txType: tx.txType,
|
|
quantity: tx.quantity,
|
|
price: tx.price,
|
|
fee: tx.fee,
|
|
txCurrency: tx.txCurrency,
|
|
executedAt: tx.executedAt,
|
|
});
|
|
}
|
|
|
|
const today = getTodayInShanghai();
|
|
const result: Position[] = [];
|
|
|
|
for (const [_, holding] of holdings) {
|
|
if (holding.quantity.lte(0)) continue;
|
|
|
|
const cnyValue = calculateCnyValueFromPrice(
|
|
holding.quantity,
|
|
holding.latestPrice,
|
|
holding.baseCurrency,
|
|
rateMap
|
|
);
|
|
|
|
// 未实现盈亏 (CNY)
|
|
const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny);
|
|
// 总盈亏 (CNY)
|
|
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
|
|
|
|
const metrics = calculateAssetMetrics(
|
|
holding.transactions.map(tx => ({
|
|
date: tx.executedAt ?? new Date(),
|
|
txType: tx.txType,
|
|
quantity: tx.quantity,
|
|
price: tx.price,
|
|
fee: tx.fee,
|
|
})),
|
|
holding.latestPrice
|
|
);
|
|
|
|
// 从动态汇率字典获取资产对人民币的汇率
|
|
const currencyKey = holding.baseCurrency || 'CNY';
|
|
const fxRate = dynamicRateMap[currencyKey] || new Big(1);
|
|
|
|
// 将引擎返回的原生币种金额折算为 CNY
|
|
const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();
|
|
const floatingPnlCny = new Big(metrics.floatingPnl).times(fxRate).toString();
|
|
const accumulatedPnlCny = new Big(metrics.accumulatedPnl).times(fxRate).toString();
|
|
const dilutedCostCny = new Big(metrics.dilutedCost).times(fxRate).toString();
|
|
|
|
const holdingNative = new Big(metrics.holdings);
|
|
const avgCostNative = new Big(metrics.averageCost);
|
|
const dilutedCostNative = new Big(metrics.dilutedCost);
|
|
const floatingPnlNative = new Big(metrics.floatingPnl);
|
|
const cumulativePnlNative = new Big(metrics.accumulatedPnl);
|
|
const marketValueNative = new Big(metrics.marketValue);
|
|
|
|
let floatingPnlPercent = new Big('0');
|
|
const avgCostBasisNative = avgCostNative.times(holdingNative);
|
|
if (avgCostBasisNative.gt(0)) {
|
|
floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100'));
|
|
}
|
|
|
|
let cumulativePnlPercent = new Big('0');
|
|
if (holding.totalBuyCostNative.gt(0)) {
|
|
cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100'));
|
|
}
|
|
|
|
let holdingDays = 0;
|
|
if (holding.firstBuyDate) {
|
|
const diffMs = today.getTime() - holding.firstBuyDate.getTime();
|
|
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,
|
|
name: holding.name,
|
|
type: holding.type,
|
|
quantity: holdingNative.toString(),
|
|
baseCurrency: holding.baseCurrency,
|
|
cnyValue: cnyValue.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: metrics.averageCost,
|
|
dilutedCost: metrics.dilutedCost,
|
|
dilutedCostCny,
|
|
floatingPnl: metrics.floatingPnl,
|
|
floatingPnlCny,
|
|
accumulatedPnl: metrics.accumulatedPnl,
|
|
accumulatedPnlCny,
|
|
marketValueCny,
|
|
holdingDays,
|
|
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(),
|
|
latestPrice: holding.latestPrice,
|
|
transactions: holding.transactions,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function getPortfolioSummary() {
|
|
const positions = await getPortfolioPositions();
|
|
|
|
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
|
let totalCnyValue = new Big('0');
|
|
let totalPnlCny = new Big('0');
|
|
let totalFloatingPnlCny = new Big('0');
|
|
|
|
for (const pos of positions) {
|
|
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
|
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
|
|
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
|
|
}
|
|
|
|
const chartData = positions.map((pos, index) => ({
|
|
name: pos.symbol,
|
|
value: new Big(pos.marketValueCny || '0').toNumber(),
|
|
fill: [
|
|
'#3b82f6',
|
|
'#8b5cf6',
|
|
'#10b981',
|
|
'#f59e0b',
|
|
'#ef4444',
|
|
'#06b6d4',
|
|
][index % 6],
|
|
}));
|
|
|
|
// 按市场维度聚合资产分布
|
|
const marketMap = new Map<string, {
|
|
market: string;
|
|
totalCnyValue: Big;
|
|
}>();
|
|
|
|
for (const pos of positions) {
|
|
const market = getMarketFromExchange(pos.exchange);
|
|
const existing = marketMap.get(market);
|
|
if (existing) {
|
|
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
|
|
} else {
|
|
marketMap.set(market, {
|
|
market,
|
|
totalCnyValue: new Big(pos.marketValueCny || '0'),
|
|
});
|
|
}
|
|
}
|
|
|
|
const marketAllocation: MarketAllocation[] = [];
|
|
let grandTotal = new Big('0');
|
|
for (const [, data] of marketMap) {
|
|
grandTotal = grandTotal.plus(data.totalCnyValue);
|
|
}
|
|
|
|
for (const [, data] of marketMap) {
|
|
const percentage = grandTotal.gt(0)
|
|
? data.totalCnyValue.div(grandTotal).times(100)
|
|
: new Big('0');
|
|
marketAllocation.push({
|
|
market: data.market,
|
|
name: data.market,
|
|
totalCnyValue: Number(data.totalCnyValue.toString()),
|
|
percentage: Number(percentage.toString()),
|
|
fill: MARKET_COLORS[data.market] || '#6b7280',
|
|
});
|
|
}
|
|
|
|
marketAllocation.sort((a, b) => b.totalCnyValue - a.totalCnyValue);
|
|
|
|
return {
|
|
positions,
|
|
totalCnyValue: totalCnyValue.toString(),
|
|
totalPnlCny: totalPnlCny.toString(),
|
|
unrealizedPnlCny: totalFloatingPnlCny.toString(),
|
|
chartData,
|
|
marketAllocation,
|
|
};
|
|
}
|