'use server'; import { db } from '@/db'; import { transactions, assets, exchangeRates } from '@/db/schema'; import Big from 'big.js'; import { asc, eq } from 'drizzle-orm'; 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; 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; } 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 rates = await db.select({ fromCurrency: exchangeRates.fromCurrency, toCurrency: exchangeRates.toCurrency, rate: exchangeRates.rate, }).from(exchangeRates); const rateMap = buildRateMap(rates); 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); // Native 原生币种计算 const marketValueNative = new Big(holding.latestPrice).times(holding.quantity); const currentNativeValue = marketValueNative; // 平均成本 (Native) = 总买入成本 (Native) / 总买入数量 let avgCostNative = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { avgCostNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity); } // 摊薄成本 (Native) = (总买入成本 - 已实现盈亏 - 累计分红) / 当前持仓数量 let dilutedCostNative = new Big('0'); if (holding.quantity.gt(0)) { dilutedCostNative = holding.totalBuyCostNative.minus(holding.realizedPnlNative).minus(holding.accumulatedDividendsNative).div(holding.quantity); } // 浮动盈亏 (Native) = 市值 - (平均成本 * 当前持仓数量) const floatingPnlNative = marketValueNative.minus(avgCostNative.times(holding.quantity)); // 浮动盈亏百分比 (Native) let floatingPnlPercent = new Big('0'); const avgCostBasisNative = avgCostNative.times(holding.quantity); if (avgCostBasisNative.gt(0)) { floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100')); } // 累计盈亏 (Native) = 浮动盈亏 + 已实现盈亏 + 累计分红 const cumulativePnlNative = floatingPnlNative.plus(holding.realizedPnlNative).plus(holding.accumulatedDividendsNative); // 累计盈亏百分比 (Native) = 累计盈亏 / 总买入成本 * 100 let cumulativePnlPercent = new Big('0'); if (holding.totalBuyCostNative.gt(0)) { cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100')); } // 平均成本 (CNY) 保留兼容 let avgCost = new Big('0'); if (holding.totalBuyQuantity.gt(0)) { avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity); } // 摊薄成本 (CNY) 保留兼容 let dilutedCost = new Big('0'); if (holding.quantity.gt(0)) { dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).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))); } // 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: holding.quantity.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: avgCost.toString(), dilutedCost: dilutedCost.toString(), 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(); const totalCnyValue = positions.reduce( (sum, pos) => sum.plus(new Big(pos.cnyValue)), new Big('0') ); const totalPnlCny = positions.reduce( (sum, pos) => sum.plus(new Big(pos.pnlCny)), 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).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.cnyValue)); } else { marketMap.set(market, { market, totalCnyValue: new Big(pos.cnyValue), }); } } 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: unrealizedPnlCny.toString(), chartData, marketAllocation, }; }