'use server'; import { db } from '@/db'; import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeRatesHistory } from '@/db/schema'; import { getPortfolioPositions } from './portfolio'; import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import Big from 'big.js'; import { calculateAssetMetrics } from '@/utils/finance'; function formatDateString(date: Date): string { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } function getTodayInShanghai(): string { const now = new Date(); const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' }); const utcDate = new Date(utcStr); const shanghaiOffset = 8 * 60 * 60 * 1000; const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); return formatDateString(shanghaiDate); } export async function recordDailySnapshot() { const positions = await getPortfolioPositions(false); // 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny const totalValueCny = positions.reduce( (sum, pos) => sum.plus(new Big(pos.marketValueCny || '0')), new Big(0) ).toString(); const totalCostCny = positions.reduce( (sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')), new Big(0) ).toString(); const dateStr = getTodayInShanghai(); const existing = await db .select() .from(portfolioSnapshots) .where(eq(portfolioSnapshots.date, dateStr)) .limit(1); const now = new Date(); if (existing.length > 0) { await db .update(portfolioSnapshots) .set({ totalValueCny, totalCostCny, updatedAt: now, }) .where(eq(portfolioSnapshots.date, dateStr)); return { success: true, action: 'updated', date: dateStr, totalValueCny, totalCostCny, }; } await db .insert(portfolioSnapshots) .values({ date: dateStr, totalValueCny, totalCostCny, createdAt: now, updatedAt: now, }); return { success: true, action: 'inserted', date: dateStr, totalValueCny, totalCostCny, }; } export async function getSnapshots(params?: { limit?: number; startDate?: string; endDate?: string; }) { const { limit, startDate, endDate } = params || {}; let query = db .select() .from(portfolioSnapshots) .orderBy(desc(portfolioSnapshots.date)) .$dynamic(); if (startDate) { query = query.where(gte(portfolioSnapshots.date, startDate)); } if (endDate) { query = query.where( lte(portfolioSnapshots.date, endDate) ); } const snapshots = limit ? await query.limit(limit) : await query; return snapshots.reverse(); } interface HistoricalPosition { assetId: string; quantity: string; totalCost: string; } export async function getHistoricalPositions(targetDate: Date): Promise { const dateStr = formatDateString(targetDate); const allTransactions = await db .select({ assetId: transactions.assetId, txType: transactions.txType, quantity: transactions.quantity, price: transactions.price, exchangeRate: transactions.exchangeRate, executedAt: transactions.executedAt, }) .from(transactions) .where( lte(transactions.executedAt, targetDate) ) .orderBy(asc(transactions.executedAt)); 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, { quantity: new Big('0'), totalCost: new Big('0'), }); } const holding = holdings.get(tx.assetId)!; const qty = new Big(tx.quantity); if (tx.txType === 'BUY') { holding.quantity = holding.quantity.plus(qty); const cost = qty.times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1')); holding.totalCost = holding.totalCost.plus(cost); } else if (tx.txType === 'SELL') { let avgCostPerUnit = new Big('0'); if (holding.quantity.gt(0)) { avgCostPerUnit = holding.totalCost.div(holding.quantity); } const sellCost = avgCostPerUnit.times(qty); holding.quantity = holding.quantity.minus(qty); holding.totalCost = holding.totalCost.minus(sellCost); } else if (tx.txType === 'AIRDROP') { holding.quantity = holding.quantity.plus(qty); } } const result: HistoricalPosition[] = []; for (const [assetId, holding] of holdings) { if (holding.quantity.lte(0)) continue; result.push({ assetId, quantity: holding.quantity.toString(), totalCost: holding.totalCost.toString(), }); } return result; } export async function getEffectivePrice( assetId: string, targetDate: Date ): Promise { const dateStr = formatDateString(targetDate); const [record] = await db .select({ price: assetPricesHistory.price, }) .from(assetPricesHistory) .where( and( eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr) ) ) .orderBy(desc(assetPricesHistory.date)) .limit(1); return record?.price ?? null; } async function buildDailyRatesMap(targetDateStr: string): Promise> { const boundaryString = `${targetDateStr} 23:59:59`; // 获取 USD/CNY — 取目标时间点之前最后一条 USD->CNY 记录 const usdRecords = await db .select({ rate: exchangeRatesHistory.rate, fetchTime: exchangeRatesHistory.fetchTime, }) .from(exchangeRatesHistory) .where( and( eq(exchangeRatesHistory.fromCurrency, 'USD'), eq(exchangeRatesHistory.toCurrency, 'CNY'), lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`) ) ) .orderBy(desc(exchangeRatesHistory.fetchTime)) .limit(1); // 获取 HKD/CNY — 取目标时间点之前最后一条 HKD->CNY 记录 const hkdRecords = await db .select({ rate: exchangeRatesHistory.rate, fetchTime: exchangeRatesHistory.fetchTime, }) .from(exchangeRatesHistory) .where( and( eq(exchangeRatesHistory.fromCurrency, 'HKD'), eq(exchangeRatesHistory.toCurrency, 'CNY'), lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`) ) ) .orderBy(desc(exchangeRatesHistory.fetchTime)) .limit(1); // 若 HKD->CNY 不存在,尝试走 HKD->USD 再 USD->CNY 的交叉换算 let hkdRateStr: string | null = hkdRecords[0]?.rate ?? null; if (!hkdRateStr) { const hkdUsdRecords = await db .select({ rate: exchangeRatesHistory.rate, fetchTime: exchangeRatesHistory.fetchTime, }) .from(exchangeRatesHistory) .where( and( eq(exchangeRatesHistory.fromCurrency, 'HKD'), eq(exchangeRatesHistory.toCurrency, 'USD'), lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`) ) ) .orderBy(desc(exchangeRatesHistory.fetchTime)) .limit(1); const usdToCnyRate = usdRecords[0]?.rate ?? null; if (hkdUsdRecords[0]?.rate && usdToCnyRate) { hkdRateStr = new Big(hkdUsdRecords[0].rate).times(new Big(usdToCnyRate)).toString(); } } const usdRateStr = usdRecords[0]?.rate ?? null; console.log(`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}`); return { USD: new Big(usdRateStr || '7.22'), HKD: new Big(hkdRateStr || '0.92'), CNY: new Big(1), }; } async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise { const [record] = await db .select({ price: assetPricesHistory.price }) .from(assetPricesHistory) .where( and( eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr) ) ) .orderBy(desc(assetPricesHistory.date)) .limit(1); if (record?.price) { return record.price; } return fallbackCostPrice; } export async function reconstructPortfolioHistory() { const [earliest] = await db .select({ executedAt: transactions.executedAt }) .from(transactions) .orderBy(asc(transactions.executedAt)) .limit(1); if (!earliest) { return { success: true, message: 'No transactions found, nothing to reconstruct.', daysReconstructed: 0, }; } const earliestDate = new Date(earliest.executedAt); const utcStr = earliestDate.toLocaleString('en-US', { timeZone: 'UTC' }); const utcDate = new Date(utcStr); const shanghaiOffset = 8 * 60 * 60 * 1000; const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); let currentDate = new Date(shanghaiDate); currentDate.setHours(0, 0, 0, 0); const todayStr = getTodayInShanghai(); const allAssets = await db .select({ id: assets.id, baseCurrency: assets.baseCurrency, }) .from(assets); const assetBaseCurrencyMap = new Map(); for (const a of allAssets) { assetBaseCurrencyMap.set(a.id, a.baseCurrency); } await db.delete(portfolioSnapshots); let daysReconstructed = 0; while (formatDateString(currentDate) <= todayStr) { const dateStr = formatDateString(currentDate); const historicalTx = await db .select({ assetId: transactions.assetId, executedAt: transactions.executedAt, txType: transactions.txType, quantity: transactions.quantity, price: transactions.price, fee: transactions.fee, exchangeRate: transactions.exchangeRate, }) .from(transactions) .where(lte(transactions.executedAt, currentDate)) .orderBy(asc(transactions.executedAt)); let totalValueCny = new Big('0'); let totalCostCny = new Big('0'); const dailyRates = await buildDailyRatesMap(dateStr); const uniqueAssetIds = [...new Set(historicalTx.filter(t => t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND' ).map(t => t.assetId))]; for (const assetId of uniqueAssetIds) { const assetTxs = historicalTx .filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND')) .map(t => ({ date: new Date(t.executedAt).toISOString().split('T')[0], txType: t.txType, quantity: t.quantity.toString(), price: t.price.toString(), fee: t.fee.toString(), })); const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD'; const costPrice = new Big(assetTxs.reduce((sum, t) => { if (t.txType === 'BUY') return sum.plus(new Big(t.price).times(new Big(t.quantity))); if (t.txType === 'SELL') return sum.minus(new Big(t.price).times(new Big(t.quantity))); return sum; }, new Big('0')).div(new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).gt(0) ? new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).toString() : '1')).toString(); const snapshotPrice = await getHistoricalPriceWithFallback(assetId, dateStr, costPrice); const currency = (baseCurrency || 'CNY').toUpperCase(); const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1); const priceStrForMetrics = snapshotPrice; const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics); const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate); // 使用交易时的真实汇率计算法币本金,而非直接用 metrics.accumulatedCost let calculatedFiatCost = new Big(0); const rawTxs = historicalTx.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND')); let currentQty = new Big(0); for (const tx of rawTxs) { const qty = new Big(tx.quantity); const fx = new Big(tx.exchangeRate || '1'); const price = new Big(tx.price); if (tx.txType === 'BUY') { currentQty = currentQty.plus(qty); calculatedFiatCost = calculatedFiatCost.plus(qty.times(price).times(fx)); } else if (tx.txType === 'SELL') { let avgFiatCostPerUnit = new Big(0); if (currentQty.gt(0)) { avgFiatCostPerUnit = calculatedFiatCost.div(currentQty); } calculatedFiatCost = calculatedFiatCost.minus(avgFiatCostPerUnit.times(qty)); currentQty = currentQty.minus(qty); } } const posCostCny = calculatedFiatCost.gt(0) ? calculatedFiatCost : new Big(0); totalValueCny = totalValueCny.plus(posValueCny); totalCostCny = totalCostCny.plus(posCostCny); } const existing = await db .select() .from(portfolioSnapshots) .where(eq(portfolioSnapshots.date, dateStr)) .limit(1); const now = new Date(); if (existing.length > 0) { await db .update(portfolioSnapshots) .set({ totalValueCny: totalValueCny.toString(), totalCostCny: totalCostCny.toString(), updatedAt: now, }) .where(eq(portfolioSnapshots.date, dateStr)); } else { await db .insert(portfolioSnapshots) .values({ date: dateStr, totalValueCny: totalValueCny.toString(), totalCostCny: totalCostCny.toString(), createdAt: now, updatedAt: now, }); } daysReconstructed++; currentDate.setDate(currentDate.getDate() + 1); } return { success: true, daysReconstructed, }; }