'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> { 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 { const map = new Map(); for (const r of rates) { map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate); } return map; } function getRate( rateMap: Map, 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 ): 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 = { '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 { 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(); for (const [currency, rate] of Object.entries(dynamicRateMap)) { if (currency !== 'CNY') { rateMap.set(`${currency}_CNY`, rate.toString()); } } const holdings = new Map(); 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(); 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, }; }