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>
537 lines
16 KiB
TypeScript
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();
|