trading-platform-backend-v2/src/modules/trading/services/export.service.ts
Adrian Flores Cortes 3bb215b51b fix(coherence): Align backend types with DDL (E-COH-001, E-COH-003)
COHERENCIA FIXES P0 (ST1.1 + ST1.2 - 45min total)

ST1.1 (E-COH-001 - 15min):
- Fixed backend UserRole enum to match DDL
- Changed: investor→user, removed student/instructor, added analyst
- Deprecated requireInstructor guard (role doesn't exist in DDL)

ST1.2 (E-COH-003 - 30min):
- Created investment.types.ts with all enums from DDL
- Centralized types: TradingAgent, RiskProfile, AccountStatus,
  DistributionFrequency, TransactionType, TransactionStatus
- Updated all imports in repositories, services, controllers

Impact:
- Type safety across auth and investment modules
- Coherence with DDL (source of truth) guaranteed
- Eliminated type duplication and inconsistencies

Modified files:
- src/modules/auth/types/auth.types.ts
- src/core/guards/auth.guard.ts
- src/modules/investment/types/investment.types.ts (NEW)
- src/modules/investment/repositories/account.repository.ts
- src/modules/investment/services/account.service.ts
- src/modules/investment/services/product.service.ts
- src/modules/investment/controllers/investment.controller.ts

Task: TASK-2026-01-26-ANALYSIS-INTEGRATION-PLAN
Subtasks: ST1.1, ST1.2
Epics: OQI-001, OQI-004
Priority: P0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:48:44 -06:00

537 lines
16 KiB
TypeScript

/**
* 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<TradeRecord[]> {
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<ExportResult> {
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<ExportResult> {
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<ExportResult> {
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<void>((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<ExportResult> {
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();