'use server'; import { db } from '@/db'; import { transactions, assets, exchangeRates } from '@/db/schema'; import Big from 'big.js'; import { desc, eq } from 'drizzle-orm'; interface Position { assetId: string; symbol: string; type: string; quantity: string; baseCurrency: string; cnyValue: string; totalCostCny: string; pnlCny: string; totalCostNative: string; pnlNative: string; } 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'); } export async function getPortfolioPositions(): Promise { const allTransactions = await db .select({ txType: transactions.txType, quantity: transactions.quantity, price: transactions.price, exchangeRate: transactions.exchangeRate, txCurrency: transactions.txCurrency, assetId: transactions.assetId, assetSymbol: assets.symbol, assetType: assets.type, assetBaseCurrency: assets.baseCurrency, assetLatestPrice: assets.latestPrice, }) .from(transactions) .leftJoin(assets, eq(assets.id, transactions.assetId)) .orderBy(desc(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, type: tx.assetType || 'CASH', quantity: new Big('0'), baseCurrency: tx.assetBaseCurrency || '', latestPrice: tx.assetLatestPrice || '0', totalCostCny: new Big('0'), totalCostNative: new Big('0'), }); } 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.totalCostNative = holding.totalCostNative.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.totalCostCny = holding.totalCostCny.plus(costCny); } 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 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 sellCostCny = sellCostPerUnit.times(new Big(appliedRate || '1')); holding.totalCostCny = holding.totalCostCny.minus(sellCostCny); } else if (tx.txType === 'AIRDROP') { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); } else if (tx.txType === 'DIVIDEND') { holding.quantity = holding.quantity.plus(new Big(tx.quantity)); } if (tx.assetLatestPrice) { holding.latestPrice = tx.assetLatestPrice; } } const result: Position[] = []; let totalCnyValue = new Big('0'); let totalPnlCny = new Big('0'); for (const [_, holding] of holdings) { if (holding.quantity.lte(0)) continue; const cnyValue = calculateCnyValueFromPrice( holding.quantity, holding.latestPrice, holding.baseCurrency, rateMap ); totalCnyValue = totalCnyValue.plus(cnyValue); const pnlCny = cnyValue.minus(holding.totalCostCny); totalPnlCny = totalPnlCny.plus(pnlCny); const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity); const pnlNative = currentNativeValue.minus(holding.totalCostNative); result.push({ assetId: holding.assetId, symbol: holding.symbol, type: holding.type, quantity: holding.quantity.toString(), baseCurrency: holding.baseCurrency, cnyValue: cnyValue.toString(), totalCostCny: holding.totalCostCny.toString(), pnlCny: pnlCny.toString(), totalCostNative: holding.totalCostNative.toString(), pnlNative: pnlNative.toString(), }); } 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 chartData = positions.map((pos, index) => ({ name: pos.symbol, value: new Big(pos.cnyValue), fill: [ '#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4', ][index % 6], })); return { positions, totalCnyValue: totalCnyValue.toString(), totalPnlCny: totalPnlCny.toString(), chartData, }; }