/** * Paper Trading Service * Simulates trading with virtual funds using PostgreSQL */ import { db } from '../../../shared/database'; import { marketService } from './market.service'; import { logger } from '../../../shared/utils/logger'; // ============================================================================ // Types (matching trading.paper_trading_accounts and paper_trading_positions) // ============================================================================ export type TradeDirection = 'long' | 'short'; export type PositionStatus = 'open' | 'closed' | 'pending'; export interface PaperAccount { id: string; userId: string; name: string; initialBalance: number; currentBalance: number; currency: string; totalTrades: number; winningTrades: number; totalPnl: number; maxDrawdown: number; isActive: boolean; createdAt: Date; updatedAt: Date; } export interface PaperPosition { id: string; accountId: string; userId: string; symbol: string; direction: TradeDirection; lotSize: number; entryPrice: number; stopLoss?: number; takeProfit?: number; exitPrice?: number; status: PositionStatus; openedAt: Date; closedAt?: Date; closeReason?: string; realizedPnl?: number; createdAt: Date; updatedAt: Date; // Calculated fields currentPrice?: number; unrealizedPnl?: number; unrealizedPnlPercent?: number; } export interface CreateAccountInput { name?: string; initialBalance?: number; currency?: string; } export interface CreatePositionInput { symbol: string; direction: TradeDirection; lotSize: number; entryPrice?: number; // If not provided, uses market price stopLoss?: number; takeProfit?: number; } export interface ClosePositionInput { exitPrice?: number; // If not provided, uses market price closeReason?: string; } export interface AccountSummary { account: PaperAccount; openPositions: number; totalEquity: number; unrealizedPnl: number; todayPnl: number; winRate: number; } // ============================================================================ // Helper Functions // ============================================================================ function mapAccount(row: Record): PaperAccount { return { id: row.id as string, userId: row.user_id as string, name: row.name as string, initialBalance: parseFloat(row.initial_balance as string), currentBalance: parseFloat(row.current_balance as string), currency: (row.currency as string).trim(), totalTrades: row.total_trades as number, winningTrades: row.winning_trades as number, totalPnl: parseFloat(row.total_pnl as string), maxDrawdown: parseFloat(row.max_drawdown as string), isActive: row.is_active as boolean, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } function mapPosition(row: Record): PaperPosition { return { id: row.id as string, accountId: row.account_id as string, userId: row.user_id as string, symbol: row.symbol as string, direction: row.direction as TradeDirection, lotSize: parseFloat(row.lot_size as string), entryPrice: parseFloat(row.entry_price as string), stopLoss: row.stop_loss ? parseFloat(row.stop_loss as string) : undefined, takeProfit: row.take_profit ? parseFloat(row.take_profit as string) : undefined, exitPrice: row.exit_price ? parseFloat(row.exit_price as string) : undefined, status: row.status as PositionStatus, openedAt: new Date(row.opened_at as string), closedAt: row.closed_at ? new Date(row.closed_at as string) : undefined, closeReason: row.close_reason as string | undefined, realizedPnl: row.realized_pnl ? parseFloat(row.realized_pnl as string) : undefined, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } // ============================================================================ // Paper Trading Service // ============================================================================ class PaperTradingService { // ========================================================================== // Account Management // ========================================================================== /** * Get or create default paper trading account for user */ async getOrCreateAccount(userId: string): Promise { // Try to find existing active account const existing = await db.query>( `SELECT * FROM trading.paper_trading_accounts WHERE user_id = $1 AND is_active = TRUE ORDER BY created_at DESC LIMIT 1`, [userId] ); if (existing.rows.length > 0) { return mapAccount(existing.rows[0]); } // Create new account with default $100,000 return this.createAccount(userId, {}); } /** * Create a new paper trading account */ async createAccount(userId: string, input: CreateAccountInput): Promise { const result = await db.query>( `INSERT INTO trading.paper_trading_accounts (user_id, name, initial_balance, current_balance, currency) VALUES ($1, $2, $3, $3, $4) RETURNING *`, [ userId, input.name || 'Paper Account', input.initialBalance || 100000, input.currency || 'USD', ] ); logger.info('[PaperTrading] Account created:', { userId, accountId: result.rows[0].id, initialBalance: input.initialBalance || 100000, }); return mapAccount(result.rows[0]); } /** * Get account by ID */ async getAccount(accountId: string, userId: string): Promise { const result = await db.query>( `SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`, [accountId, userId] ); if (result.rows.length === 0) return null; return mapAccount(result.rows[0]); } /** * Get all accounts for user */ async getUserAccounts(userId: string): Promise { const result = await db.query>( `SELECT * FROM trading.paper_trading_accounts WHERE user_id = $1 ORDER BY created_at DESC`, [userId] ); return result.rows.map(mapAccount); } /** * Reset account to initial state */ async resetAccount(accountId: string, userId: string): Promise { const client = await db.getClient(); try { await client.query('BEGIN'); // Get account const accountResult = await client.query>( `SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`, [accountId, userId] ); if (accountResult.rows.length === 0) { await client.query('ROLLBACK'); return null; } // Close all open positions await client.query( `UPDATE trading.paper_trading_positions SET status = 'closed', closed_at = NOW(), close_reason = 'account_reset' WHERE account_id = $1 AND status = 'open'`, [accountId] ); // Reset account const result = await client.query>( `UPDATE trading.paper_trading_accounts SET current_balance = initial_balance, total_trades = 0, winning_trades = 0, total_pnl = 0, max_drawdown = 0, updated_at = NOW() WHERE id = $1 RETURNING *`, [accountId] ); await client.query('COMMIT'); logger.info('[PaperTrading] Account reset:', { accountId, userId }); return mapAccount(result.rows[0]); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } // ========================================================================== // Position Management // ========================================================================== /** * Open a new position */ async openPosition(userId: string, input: CreatePositionInput): Promise { // Get or create account const account = await this.getOrCreateAccount(userId); // Get market price if not provided let entryPrice = input.entryPrice; if (!entryPrice) { try { const priceData = await marketService.getPrice(input.symbol); entryPrice = priceData.price; } catch { throw new Error(`Could not get price for ${input.symbol}`); } } // Calculate required margin (simplified: lot_size * entry_price) const requiredMargin = input.lotSize * entryPrice; if (requiredMargin > account.currentBalance) { throw new Error( `Insufficient balance. Required: ${requiredMargin.toFixed(2)}, Available: ${account.currentBalance.toFixed(2)}` ); } const client = await db.getClient(); try { await client.query('BEGIN'); // Create position const result = await client.query>( `INSERT INTO trading.paper_trading_positions (account_id, user_id, symbol, direction, lot_size, entry_price, stop_loss, take_profit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ account.id, userId, input.symbol.toUpperCase(), input.direction, input.lotSize, entryPrice, input.stopLoss || null, input.takeProfit || null, ] ); // Deduct margin from balance await client.query( `UPDATE trading.paper_trading_accounts SET current_balance = current_balance - $1, updated_at = NOW() WHERE id = $2`, [requiredMargin, account.id] ); await client.query('COMMIT'); logger.info('[PaperTrading] Position opened:', { positionId: result.rows[0].id, userId, symbol: input.symbol, direction: input.direction, lotSize: input.lotSize, entryPrice, }); return mapPosition(result.rows[0]); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Close a position */ async closePosition( positionId: string, userId: string, input: ClosePositionInput = {} ): Promise { const client = await db.getClient(); try { await client.query('BEGIN'); // Get position const positionResult = await client.query>( `SELECT * FROM trading.paper_trading_positions WHERE id = $1 AND user_id = $2 AND status = 'open'`, [positionId, userId] ); if (positionResult.rows.length === 0) { await client.query('ROLLBACK'); return null; } const position = mapPosition(positionResult.rows[0]); // Get exit price let exitPrice = input.exitPrice; if (!exitPrice) { try { const priceData = await marketService.getPrice(position.symbol); exitPrice = priceData.price; } catch { throw new Error(`Could not get price for ${position.symbol}`); } } // Calculate P&L const priceDiff = exitPrice - position.entryPrice; const realizedPnl = position.direction === 'long' ? priceDiff * position.lotSize : -priceDiff * position.lotSize; // Update position const result = await client.query>( `UPDATE trading.paper_trading_positions SET status = 'closed', exit_price = $1, closed_at = NOW(), close_reason = $2, realized_pnl = $3, updated_at = NOW() WHERE id = $4 RETURNING *`, [exitPrice, input.closeReason || 'manual', realizedPnl, positionId] ); // Update account balance and stats const marginReturn = position.lotSize * position.entryPrice; const isWin = realizedPnl > 0; await client.query( `UPDATE trading.paper_trading_accounts SET current_balance = current_balance + $1 + $2, total_trades = total_trades + 1, winning_trades = winning_trades + $3, total_pnl = total_pnl + $2, updated_at = NOW() WHERE id = $4`, [marginReturn, realizedPnl, isWin ? 1 : 0, position.accountId] ); // Update max drawdown if needed await this.updateMaxDrawdown(client, position.accountId); await client.query('COMMIT'); logger.info('[PaperTrading] Position closed:', { positionId, userId, exitPrice, realizedPnl, }); return mapPosition(result.rows[0]); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Get position by ID */ async getPosition(positionId: string, userId: string): Promise { const result = await db.query>( `SELECT * FROM trading.paper_trading_positions WHERE id = $1 AND user_id = $2`, [positionId, userId] ); if (result.rows.length === 0) return null; const position = mapPosition(result.rows[0]); // Add current price and unrealized P&L for open positions if (position.status === 'open') { await this.enrichPositionWithMarketData(position); } return position; } /** * Get user positions */ async getPositions( userId: string, options: { accountId?: string; status?: PositionStatus; symbol?: string; limit?: number } = {} ): Promise { const conditions: string[] = ['user_id = $1']; const params: (string | number)[] = [userId]; let paramIndex = 2; if (options.accountId) { conditions.push(`account_id = $${paramIndex++}`); params.push(options.accountId); } if (options.status) { conditions.push(`status = $${paramIndex++}`); params.push(options.status); } if (options.symbol) { conditions.push(`symbol = $${paramIndex++}`); params.push(options.symbol.toUpperCase()); } let query = `SELECT * FROM trading.paper_trading_positions WHERE ${conditions.join(' AND ')} ORDER BY opened_at DESC`; if (options.limit) { query += ` LIMIT $${paramIndex}`; params.push(options.limit); } const result = await db.query>(query, params); const positions = result.rows.map(mapPosition); // Enrich open positions with market data for (const position of positions) { if (position.status === 'open') { await this.enrichPositionWithMarketData(position); } } return positions; } /** * Update position stop loss / take profit */ async updatePosition( positionId: string, userId: string, updates: { stopLoss?: number; takeProfit?: number } ): Promise { const fields: string[] = []; const params: (string | number | null)[] = []; let paramIndex = 1; if (updates.stopLoss !== undefined) { fields.push(`stop_loss = $${paramIndex++}`); params.push(updates.stopLoss); } if (updates.takeProfit !== undefined) { fields.push(`take_profit = $${paramIndex++}`); params.push(updates.takeProfit); } if (fields.length === 0) { return this.getPosition(positionId, userId); } fields.push(`updated_at = NOW()`); params.push(positionId, userId); const result = await db.query>( `UPDATE trading.paper_trading_positions SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} AND status = 'open' RETURNING *`, params ); if (result.rows.length === 0) return null; return mapPosition(result.rows[0]); } // ========================================================================== // Account Summary & Analytics // ========================================================================== /** * Get account summary with live data */ async getAccountSummary(userId: string, accountId?: string): Promise { // Get account let account: PaperAccount | null; if (accountId) { account = await this.getAccount(accountId, userId); } else { account = await this.getOrCreateAccount(userId); } if (!account) return null; // Get open positions const openPositions = await this.getPositions(userId, { accountId: account.id, status: 'open', }); // Calculate unrealized P&L let unrealizedPnl = 0; for (const position of openPositions) { unrealizedPnl += position.unrealizedPnl || 0; } // Calculate total equity const totalEquity = account.currentBalance + unrealizedPnl; // Calculate today's P&L const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const todayResult = await db.query<{ today_pnl: string }>( `SELECT COALESCE(SUM(realized_pnl), 0) as today_pnl FROM trading.paper_trading_positions WHERE account_id = $1 AND closed_at >= $2`, [account.id, todayStart.toISOString()] ); const todayPnl = parseFloat(todayResult.rows[0].today_pnl) + unrealizedPnl; // Calculate win rate const winRate = account.totalTrades > 0 ? (account.winningTrades / account.totalTrades) * 100 : 0; return { account, openPositions: openPositions.length, totalEquity, unrealizedPnl, todayPnl, winRate, }; } /** * Get trade history */ async getTradeHistory( userId: string, options: { accountId?: string; symbol?: string; startDate?: Date; endDate?: Date; limit?: number; } = {} ): Promise { const conditions: string[] = ['user_id = $1', "status = 'closed'"]; const params: (string | number)[] = [userId]; let paramIndex = 2; if (options.accountId) { conditions.push(`account_id = $${paramIndex++}`); params.push(options.accountId); } if (options.symbol) { conditions.push(`symbol = $${paramIndex++}`); params.push(options.symbol.toUpperCase()); } if (options.startDate) { conditions.push(`closed_at >= $${paramIndex++}`); params.push(options.startDate.toISOString()); } if (options.endDate) { conditions.push(`closed_at <= $${paramIndex++}`); params.push(options.endDate.toISOString()); } let query = `SELECT * FROM trading.paper_trading_positions WHERE ${conditions.join(' AND ')} ORDER BY closed_at DESC`; if (options.limit) { query += ` LIMIT $${paramIndex}`; params.push(options.limit); } const result = await db.query>(query, params); return result.rows.map(mapPosition); } /** * Get performance statistics */ async getPerformanceStats( userId: string, accountId?: string ): Promise<{ totalTrades: number; winningTrades: number; losingTrades: number; winRate: number; totalPnl: number; averageWin: number; averageLoss: number; largestWin: number; largestLoss: number; profitFactor: number; }> { const account = accountId ? await this.getAccount(accountId, userId) : await this.getOrCreateAccount(userId); if (!account) { throw new Error('Account not found'); } const result = await db.query>( `SELECT COUNT(*) as total_trades, COUNT(*) FILTER (WHERE realized_pnl > 0) as winning_trades, COUNT(*) FILTER (WHERE realized_pnl <= 0) as losing_trades, COALESCE(SUM(realized_pnl), 0) as total_pnl, COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as avg_win, COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl <= 0), 0) as avg_loss, COALESCE(MAX(realized_pnl), 0) as largest_win, COALESCE(MIN(realized_pnl), 0) as largest_loss, COALESCE(SUM(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as gross_profit, COALESCE(ABS(SUM(realized_pnl) FILTER (WHERE realized_pnl < 0)), 1) as gross_loss FROM trading.paper_trading_positions WHERE account_id = $1 AND status = 'closed'`, [account.id] ); const stats = result.rows[0]; const totalTrades = parseInt(stats.total_trades, 10); const winningTrades = parseInt(stats.winning_trades, 10); const grossProfit = parseFloat(stats.gross_profit); const grossLoss = parseFloat(stats.gross_loss); return { totalTrades, winningTrades, losingTrades: parseInt(stats.losing_trades, 10), winRate: totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0, totalPnl: parseFloat(stats.total_pnl), averageWin: parseFloat(stats.avg_win), averageLoss: parseFloat(stats.avg_loss), largestWin: parseFloat(stats.largest_win), largestLoss: parseFloat(stats.largest_loss), profitFactor: grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0, }; } // ========================================================================== // Private Helpers // ========================================================================== private async enrichPositionWithMarketData(position: PaperPosition): Promise { try { const priceData = await marketService.getPrice(position.symbol); position.currentPrice = priceData.price; const priceDiff = priceData.price - position.entryPrice; position.unrealizedPnl = position.direction === 'long' ? priceDiff * position.lotSize : -priceDiff * position.lotSize; position.unrealizedPnlPercent = ((position.direction === 'long' ? priceDiff : -priceDiff) / position.entryPrice) * 100; } catch { // Keep position without market data if fetch fails logger.debug('[PaperTrading] Could not get price for position:', { positionId: position.id, symbol: position.symbol, }); } } private async updateMaxDrawdown(client: { query: typeof db.query }, accountId: string): Promise { // Calculate max drawdown from equity curve const result = await client.query>( `WITH equity_changes AS ( SELECT current_balance as initial, current_balance + COALESCE( (SELECT SUM(realized_pnl) FROM trading.paper_trading_positions WHERE account_id = $1 AND status = 'closed'), 0 ) as current FROM trading.paper_trading_accounts WHERE id = $1 ) SELECT CASE WHEN initial > 0 THEN GREATEST(0, (initial - current) / initial * 100) ELSE 0 END as drawdown FROM equity_changes`, [accountId] ); if (result.rows.length > 0) { const currentDrawdown = parseFloat(result.rows[0].drawdown); await client.query( `UPDATE trading.paper_trading_accounts SET max_drawdown = GREATEST(max_drawdown, $1) WHERE id = $2`, [currentDrawdown, accountId] ); } } } // Export singleton instance export const paperTradingService = new PaperTradingService();