/** * Export Service * Handles exporting trading history to CSV, Excel, and PDF formats */ import { db } from '../../../shared/database'; import { logger } from '../../../shared/utils/logger'; import { format } from 'date-fns'; // ============================================================================ // Types // ============================================================================ export interface ExportFilters { startDate?: Date; endDate?: Date; symbols?: string[]; status?: 'open' | 'closed' | 'all'; direction?: 'long' | 'short' | 'all'; } export interface TradeRecord { id: string; symbol: string; direction: 'long' | 'short'; lotSize: number; entryPrice: number; exitPrice: number | null; stopLoss: number | null; takeProfit: number | null; status: string; openedAt: Date; closedAt: Date | null; realizedPnl: number | null; realizedPnlPercent: number | null; closeReason: string | null; } export interface ExportResult { data: Buffer; filename: string; mimeType: string; } // ============================================================================ // Helper Functions // ============================================================================ function formatDate(date: Date | null): string { if (!date) return ''; return format(date, 'yyyy-MM-dd HH:mm:ss'); } function formatCurrency(value: number | null): string { if (value === null) return ''; return value.toFixed(2); } function formatPercent(value: number | null): string { if (value === null) return ''; return `${value.toFixed(2)}%`; } function calculatePnlPercent(trade: TradeRecord): number | null { if (trade.realizedPnl === null || trade.entryPrice === 0) return null; const investment = trade.entryPrice * trade.lotSize; return (trade.realizedPnl / investment) * 100; } // ============================================================================ // Export Service Class // ============================================================================ class ExportService { /** * Fetch trades from database */ private async fetchTrades(userId: string, filters: ExportFilters): Promise { const conditions: string[] = ['p.user_id = $1']; const values: (string | Date)[] = [userId]; let paramIndex = 2; if (filters.startDate) { conditions.push(`p.opened_at >= $${paramIndex++}`); values.push(filters.startDate); } if (filters.endDate) { conditions.push(`p.opened_at <= $${paramIndex++}`); values.push(filters.endDate); } if (filters.symbols && filters.symbols.length > 0) { conditions.push(`p.symbol = ANY($${paramIndex++})`); values.push(filters.symbols as unknown as string); } if (filters.status && filters.status !== 'all') { conditions.push(`p.status = $${paramIndex++}`); values.push(filters.status); } if (filters.direction && filters.direction !== 'all') { conditions.push(`p.direction = $${paramIndex++}`); values.push(filters.direction); } const query = ` SELECT p.id, p.symbol, p.direction, p.lot_size, p.entry_price, p.exit_price, p.stop_loss, p.take_profit, p.status, p.opened_at, p.closed_at, p.realized_pnl, p.close_reason FROM trading.paper_trading_positions p WHERE ${conditions.join(' AND ')} ORDER BY p.opened_at DESC `; const result = await db.query<{ id: string; symbol: string; direction: 'long' | 'short'; lot_size: string; entry_price: string; exit_price: string | null; stop_loss: string | null; take_profit: string | null; status: string; opened_at: Date; closed_at: Date | null; realized_pnl: string | null; close_reason: string | null; }>(query, values); return result.rows.map((row) => { const trade: TradeRecord = { id: row.id, symbol: row.symbol, direction: row.direction, lotSize: parseFloat(row.lot_size), entryPrice: parseFloat(row.entry_price), exitPrice: row.exit_price ? parseFloat(row.exit_price) : null, stopLoss: row.stop_loss ? parseFloat(row.stop_loss) : null, takeProfit: row.take_profit ? parseFloat(row.take_profit) : null, status: row.status, openedAt: row.opened_at, closedAt: row.closed_at, realizedPnl: row.realized_pnl ? parseFloat(row.realized_pnl) : null, realizedPnlPercent: null, closeReason: row.close_reason, }; trade.realizedPnlPercent = calculatePnlPercent(trade); return trade; }); } /** * Export trades to CSV format */ async exportToCSV(userId: string, filters: ExportFilters = {}): Promise { logger.info('Exporting trades to CSV', { userId, filters }); const trades = await this.fetchTrades(userId, filters); // CSV header const headers = [ 'Trade ID', 'Symbol', 'Direction', 'Lot Size', 'Entry Price', 'Exit Price', 'Stop Loss', 'Take Profit', 'Status', 'Opened At', 'Closed At', 'Realized P&L', 'P&L %', 'Close Reason', ]; // CSV rows const rows = trades.map((trade) => [ trade.id, trade.symbol, trade.direction.toUpperCase(), trade.lotSize.toString(), formatCurrency(trade.entryPrice), formatCurrency(trade.exitPrice), formatCurrency(trade.stopLoss), formatCurrency(trade.takeProfit), trade.status.toUpperCase(), formatDate(trade.openedAt), formatDate(trade.closedAt), formatCurrency(trade.realizedPnl), formatPercent(trade.realizedPnlPercent), trade.closeReason || '', ]); // Build CSV string const escapeCsvField = (field: string): string => { if (field.includes(',') || field.includes('"') || field.includes('\n')) { return `"${field.replace(/"/g, '""')}"`; } return field; }; const csvLines = [ headers.map(escapeCsvField).join(','), ...rows.map((row) => row.map(escapeCsvField).join(',')), ]; const csvContent = csvLines.join('\n'); const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.csv`; return { data: Buffer.from(csvContent, 'utf-8'), filename, mimeType: 'text/csv', }; } /** * Export trades to Excel format */ async exportToExcel(userId: string, filters: ExportFilters = {}): Promise { logger.info('Exporting trades to Excel', { userId, filters }); // Dynamic import to avoid loading exceljs if not needed const ExcelJS = await import('exceljs'); const workbook = new ExcelJS.default.Workbook(); workbook.creator = 'Trading Platform'; workbook.created = new Date(); const trades = await this.fetchTrades(userId, filters); // Trades sheet const worksheet = workbook.addWorksheet('Trading History', { properties: { tabColor: { argb: '4F46E5' } }, }); // Define columns worksheet.columns = [ { header: 'Trade ID', key: 'id', width: 20 }, { header: 'Symbol', key: 'symbol', width: 12 }, { header: 'Direction', key: 'direction', width: 10 }, { header: 'Lot Size', key: 'lotSize', width: 12 }, { header: 'Entry Price', key: 'entryPrice', width: 14 }, { header: 'Exit Price', key: 'exitPrice', width: 14 }, { header: 'Stop Loss', key: 'stopLoss', width: 14 }, { header: 'Take Profit', key: 'takeProfit', width: 14 }, { header: 'Status', key: 'status', width: 10 }, { header: 'Opened At', key: 'openedAt', width: 20 }, { header: 'Closed At', key: 'closedAt', width: 20 }, { header: 'Realized P&L', key: 'realizedPnl', width: 14 }, { header: 'P&L %', key: 'pnlPercent', width: 10 }, { header: 'Close Reason', key: 'closeReason', width: 15 }, ]; // Style header row const headerRow = worksheet.getRow(1); headerRow.font = { bold: true, color: { argb: 'FFFFFF' } }; headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '4F46E5' }, }; headerRow.alignment = { horizontal: 'center' }; // Add data rows trades.forEach((trade) => { const row = worksheet.addRow({ id: trade.id, symbol: trade.symbol, direction: trade.direction.toUpperCase(), lotSize: trade.lotSize, entryPrice: trade.entryPrice, exitPrice: trade.exitPrice, stopLoss: trade.stopLoss, takeProfit: trade.takeProfit, status: trade.status.toUpperCase(), openedAt: trade.openedAt, closedAt: trade.closedAt, realizedPnl: trade.realizedPnl, pnlPercent: trade.realizedPnlPercent ? trade.realizedPnlPercent / 100 : null, closeReason: trade.closeReason, }); // Color P&L cells const pnlCell = row.getCell('realizedPnl'); if (trade.realizedPnl !== null) { pnlCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: trade.realizedPnl >= 0 ? 'D1FAE5' : 'FEE2E2' }, }; pnlCell.font = { color: { argb: trade.realizedPnl >= 0 ? '059669' : 'DC2626' }, }; } // Format P&L percent const pnlPercentCell = row.getCell('pnlPercent'); pnlPercentCell.numFmt = '0.00%'; }); // Add summary sheet const summarySheet = workbook.addWorksheet('Summary', { properties: { tabColor: { argb: '10B981' } }, }); const totalTrades = trades.length; const closedTrades = trades.filter((t) => t.status === 'closed'); const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); summarySheet.columns = [ { header: 'Metric', key: 'metric', width: 25 }, { header: 'Value', key: 'value', width: 20 }, ]; const summaryHeaderRow = summarySheet.getRow(1); summaryHeaderRow.font = { bold: true, color: { argb: 'FFFFFF' } }; summaryHeaderRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '10B981' }, }; summarySheet.addRows([ { metric: 'Total Trades', value: totalTrades }, { metric: 'Closed Trades', value: closedTrades.length }, { metric: 'Winning Trades', value: winningTrades.length }, { metric: 'Losing Trades', value: closedTrades.length - winningTrades.length }, { metric: 'Win Rate', value: closedTrades.length > 0 ? `${((winningTrades.length / closedTrades.length) * 100).toFixed(1)}%` : 'N/A' }, { metric: 'Total P&L', value: `$${totalPnl.toFixed(2)}` }, { metric: 'Export Date', value: format(new Date(), 'yyyy-MM-dd HH:mm:ss') }, ]); // Generate buffer const buffer = await workbook.xlsx.writeBuffer(); const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.xlsx`; return { data: Buffer.from(buffer), filename, mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }; } /** * Export trades to PDF format */ async exportToPDF(userId: string, filters: ExportFilters = {}): Promise { logger.info('Exporting trades to PDF', { userId, filters }); // Dynamic import to avoid loading pdfkit if not needed const PDFDocument = (await import('pdfkit')).default; const trades = await this.fetchTrades(userId, filters); // Create PDF document const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30, }); const buffers: Buffer[] = []; doc.on('data', (chunk: Buffer) => buffers.push(chunk)); // Title doc.fontSize(20).font('Helvetica-Bold').text('Trading History Report', { align: 'center' }); doc.moveDown(0.5); doc.fontSize(10).font('Helvetica').text(`Generated: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}`, { align: 'center' }); doc.moveDown(1); // Summary section const closedTrades = trades.filter((t) => t.status === 'closed'); const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); doc.fontSize(12).font('Helvetica-Bold').text('Summary'); doc.moveDown(0.3); doc.fontSize(10).font('Helvetica'); doc.text(`Total Trades: ${trades.length}`); doc.text(`Closed Trades: ${closedTrades.length}`); doc.text(`Win Rate: ${closedTrades.length > 0 ? ((winningTrades.length / closedTrades.length) * 100).toFixed(1) : 0}%`); doc.text(`Total P&L: $${totalPnl.toFixed(2)}`, { continued: false, }); doc.moveDown(1); // Table header const tableTop = doc.y; const columns = [ { header: 'Symbol', width: 70 }, { header: 'Dir', width: 40 }, { header: 'Size', width: 50 }, { header: 'Entry', width: 70 }, { header: 'Exit', width: 70 }, { header: 'Status', width: 60 }, { header: 'Opened', width: 100 }, { header: 'Closed', width: 100 }, { header: 'P&L', width: 70 }, ]; let x = 30; doc.font('Helvetica-Bold').fontSize(8); // Draw header background doc.fillColor('#4F46E5').rect(30, tableTop - 5, 730, 18).fill(); doc.fillColor('#FFFFFF'); columns.forEach((col) => { doc.text(col.header, x, tableTop, { width: col.width, align: 'center' }); x += col.width; }); // Draw table rows doc.font('Helvetica').fontSize(8).fillColor('#000000'); let rowY = tableTop + 18; trades.slice(0, 30).forEach((trade, index) => { // Alternate row background if (index % 2 === 0) { doc.fillColor('#F3F4F6').rect(30, rowY - 3, 730, 14).fill(); } doc.fillColor('#000000'); x = 30; const values = [ trade.symbol, trade.direction.toUpperCase(), trade.lotSize.toFixed(2), `$${trade.entryPrice.toFixed(2)}`, trade.exitPrice ? `$${trade.exitPrice.toFixed(2)}` : '-', trade.status.toUpperCase(), format(trade.openedAt, 'MM/dd/yy HH:mm'), trade.closedAt ? format(trade.closedAt, 'MM/dd/yy HH:mm') : '-', trade.realizedPnl !== null ? `$${trade.realizedPnl.toFixed(2)}` : '-', ]; values.forEach((value, colIndex) => { // Color P&L if (colIndex === 8 && trade.realizedPnl !== null) { doc.fillColor(trade.realizedPnl >= 0 ? '#059669' : '#DC2626'); } doc.text(value, x, rowY, { width: columns[colIndex].width, align: 'center' }); doc.fillColor('#000000'); x += columns[colIndex].width; }); rowY += 14; // Check if we need a new page if (rowY > 550) { doc.addPage(); rowY = 30; } }); if (trades.length > 30) { doc.moveDown(2); doc.text(`... and ${trades.length - 30} more trades. Export to Excel for complete data.`, { align: 'center' }); } // Footer doc.fontSize(8).text('Trading Platform - Confidential', 30, 570, { align: 'center' }); // Finalize doc.end(); // Wait for PDF generation to complete await new Promise((resolve) => doc.on('end', resolve)); const pdfBuffer = Buffer.concat(buffers); const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.pdf`; return { data: pdfBuffer, filename, mimeType: 'application/pdf', }; } /** * Export trades to JSON format (for API consistency) */ async exportToJSON(userId: string, filters: ExportFilters = {}): Promise { logger.info('Exporting trades to JSON', { userId, filters }); const trades = await this.fetchTrades(userId, filters); const closedTrades = trades.filter((t) => t.status === 'closed'); const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); const exportData = { exportedAt: new Date().toISOString(), filters, summary: { totalTrades: trades.length, closedTrades: closedTrades.length, winningTrades: winningTrades.length, losingTrades: closedTrades.length - winningTrades.length, winRate: closedTrades.length > 0 ? (winningTrades.length / closedTrades.length) * 100 : 0, totalPnl, }, trades, }; const jsonContent = JSON.stringify(exportData, null, 2); const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.json`; return { data: Buffer.from(jsonContent, 'utf-8'), filename, mimeType: 'application/json', }; } } // Export singleton instance export const exportService = new ExportService();