From 60b03e063a9dcf9fb3eabc697892e8caded3e57b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Thu, 5 Feb 2026 23:17:17 -0600 Subject: [PATCH] [REMEDIATION] feat: Complete backend remediation for audit, auth, market-data and trading modules Add audit module (routes, controller, service, types), two-factor auth controller, expanded market-data service/controller/routes, notification types, and trading bots controller/service. Addresses gaps identified in TASK-2026-02-05 analysis. Co-Authored-By: Claude Opus 4.6 --- src/modules/audit/audit.routes.ts | 55 ++ .../audit/controllers/audit.controller.ts | 208 +++++++ src/modules/audit/index.ts | 11 + src/modules/audit/services/audit.service.ts | 249 +++++++++ src/modules/audit/types/audit.types.ts | 91 ++++ src/modules/auth/auth.routes.ts | 30 +- src/modules/auth/controllers/index.ts | 2 + .../auth/controllers/two-factor.controller.ts | 150 ++++++ src/modules/auth/types/auth.types.ts | 8 + .../controllers/market-data.controller.ts | 179 +++++- src/modules/market-data/dto/get-ohlcv.dto.ts | 136 +++++ src/modules/market-data/dto/get-ticker.dto.ts | 68 +++ src/modules/market-data/dto/index.ts | 40 ++ src/modules/market-data/index.ts | 19 +- src/modules/market-data/market-data.routes.ts | 85 ++- .../services/marketData.service.ts | 327 ++++++++++- .../market-data/types/market-data.types.ts | 47 ++ src/modules/notifications/index.ts | 33 +- .../types/notifications.types.ts | 269 ++++++++++ .../trading/controllers/bots.controller.ts | 488 +++++++++++++++++ src/modules/trading/services/bots.service.ts | 508 ++++++++++++++++++ src/modules/trading/trading.routes.ts | 79 ++- 22 files changed, 3071 insertions(+), 11 deletions(-) create mode 100644 src/modules/market-data/dto/get-ohlcv.dto.ts create mode 100644 src/modules/market-data/dto/get-ticker.dto.ts create mode 100644 src/modules/market-data/dto/index.ts create mode 100644 src/modules/notifications/types/notifications.types.ts create mode 100644 src/modules/trading/controllers/bots.controller.ts create mode 100644 src/modules/trading/services/bots.service.ts diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts index 09b8967..eeb6e62 100644 --- a/src/modules/audit/audit.routes.ts +++ b/src/modules/audit/audit.routes.ts @@ -94,4 +94,59 @@ router.post( authHandler(auditController.createSecurityEvent) ); +// ============================================================================ +// Simplified Event Endpoints (per task requirements) +// ============================================================================ + +/** + * GET /api/v1/audit/events + * Get recent events (admin only) + * Query params: limit + */ +router.get('/events', requireAuth, requireAdmin, authHandler(auditController.getEvents)); + +/** + * GET /api/v1/audit/events/search + * Search events with filters (admin only) + * Query params: userId, eventType, action, resourceType, resourceId, + * ipAddress, dateFrom, dateTo, searchText, severity, limit, offset + */ +router.get( + '/events/search', + requireAuth, + requireAdmin, + authHandler(auditController.searchEvents) +); + +/** + * GET /api/v1/audit/events/by-type/:eventType + * Get events by type (admin only) + * Query params: from, to, limit + */ +router.get( + '/events/by-type/:eventType', + requireAuth, + requireAdmin, + authHandler(auditController.getEventsByType) +); + +/** + * GET /api/v1/audit/events/by-user/:userId + * Get events for a specific user (admin only) + * Query params: from, to, limit + */ +router.get( + '/events/by-user/:userId', + requireAuth, + requireAdmin, + authHandler(auditController.getEventsByUser) +); + +/** + * POST /api/v1/audit/events + * Create a simple event (internal/admin use) + * Body: { eventType, action, resourceType?, resourceId?, metadata? } + */ +router.post('/events', requireAuth, requireAdmin, authHandler(auditController.createEvent)); + export { router as auditRouter }; diff --git a/src/modules/audit/controllers/audit.controller.ts b/src/modules/audit/controllers/audit.controller.ts index 80460af..23f0c78 100644 --- a/src/modules/audit/controllers/audit.controller.ts +++ b/src/modules/audit/controllers/audit.controller.ts @@ -11,6 +11,8 @@ import type { AuditLogFilters, SecurityEventFilters, ComplianceLogFilters, + AuditSearchQuery, + AuditEventInput, } from '../types/audit.types'; /** @@ -326,3 +328,209 @@ export async function createSecurityEvent( }); } } + +// ============================================================================ +// Simplified Event Endpoints (per task requirements) +// ============================================================================ + +/** + * GET /api/v1/audit/events + * Get all events with filters (admin only) + * Query params: limit, offset + */ +export async function getEvents( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { limit } = req.query; + const parsedLimit = limit ? parseInt(limit as string, 10) : 100; + + const events = await auditService.getRecentEvents(parsedLimit); + + res.json({ + success: true, + data: events, + meta: { + count: events.length, + limit: parsedLimit, + }, + }); + } catch (error) { + logger.error('[AuditController] Failed to get events:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve events', + }); + } +} + +/** + * GET /api/v1/audit/events/search + * Search events with advanced filters (admin only) + * Query params: userId, eventType, action, resourceType, resourceId, ipAddress, + * dateFrom, dateTo, searchText, severity, limit, offset + */ +export async function searchEvents( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { + userId, + eventType, + action, + resourceType, + resourceId, + ipAddress, + dateFrom, + dateTo, + searchText, + severity, + limit, + offset, + } = req.query; + + const query: AuditSearchQuery = { + userId: userId as string | undefined, + eventType: eventType as string | undefined, + action: action as string | undefined, + resourceType: resourceType as string | undefined, + resourceId: resourceId as string | undefined, + ipAddress: ipAddress as string | undefined, + searchText: searchText as string | undefined, + severity: severity as AuditSearchQuery['severity'], + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : 100, + offset: offset ? parseInt(offset as string, 10) : 0, + }; + + const events = await auditService.searchEvents(query); + + res.json({ + success: true, + data: events, + meta: { + count: events.length, + query: { + ...query, + dateFrom: query.dateFrom?.toISOString(), + dateTo: query.dateTo?.toISOString(), + }, + }, + }); + } catch (error) { + logger.error('[AuditController] Failed to search events:', error); + res.status(500).json({ + success: false, + error: 'Failed to search events', + }); + } +} + +/** + * POST /api/v1/audit/events + * Create a simple event entry (internal use) + * Body: { eventType, action, resourceType?, resourceId?, metadata? } + */ +export async function createEvent( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const data = req.body as AuditEventInput; + + await auditService.logEvent({ + ...data, + userId: data.userId || req.user!.id, + ipAddress: data.ipAddress || (req.ip as string), + userAgent: data.userAgent || req.headers['user-agent'], + }); + + res.status(201).json({ + success: true, + message: 'Event logged successfully', + }); + } catch (error) { + logger.error('[AuditController] Failed to create event:', error); + res.status(500).json({ + success: false, + error: 'Failed to create event', + }); + } +} + +/** + * GET /api/v1/audit/events/by-type/:eventType + * Get events by type (admin only) + * Params: eventType (e.g., 'auth.login', 'trading.order') + * Query params: from, to, limit + */ +export async function getEventsByType( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { eventType } = req.params; + const { from, to, limit } = req.query; + + const events = await auditService.getEventsByType(eventType, { + from: from ? new Date(from as string) : undefined, + to: to ? new Date(to as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : 100, + }); + + res.json({ + success: true, + data: events, + meta: { + eventType, + count: events.length, + }, + }); + } catch (error) { + logger.error('[AuditController] Failed to get events by type:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve events by type', + }); + } +} + +/** + * GET /api/v1/audit/events/by-user/:userId + * Get events for a specific user (admin only) + * Params: userId + * Query params: from, to, limit + */ +export async function getEventsByUser( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { userId } = req.params; + const { from, to, limit } = req.query; + + const events = await auditService.getEventsByUser(userId, { + from: from ? new Date(from as string) : undefined, + to: to ? new Date(to as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : 100, + }); + + res.json({ + success: true, + data: events, + meta: { + userId, + count: events.length, + }, + }); + } catch (error) { + logger.error('[AuditController] Failed to get events by user:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve events for user', + }); + } +} diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts index cf13f85..5bd3bea 100644 --- a/src/modules/audit/index.ts +++ b/src/modules/audit/index.ts @@ -5,4 +5,15 @@ export { auditRouter } from './audit.routes'; export { auditService } from './services/audit.service'; + +// Export all types export * from './types/audit.types'; + +// Re-export commonly used types for convenience +export type { + AuditEvent, + AuditEventInput, + AuditSearchQuery, + AuditQueryOptions, + CommonEventType, +} from './types/audit.types'; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts index a6a7925..d207d79 100644 --- a/src/modules/audit/services/audit.service.ts +++ b/src/modules/audit/services/audit.service.ts @@ -18,6 +18,10 @@ import type { AuditStats, AuditEventType, EventSeverity, + AuditEvent, + AuditEventInput, + AuditSearchQuery, + AuditQueryOptions, } from '../types/audit.types'; interface AuditLogRow { @@ -564,6 +568,251 @@ class AuditService { return stats; } + // ============================================================================ + // Simplified Event Methods (per task requirements) + // ============================================================================ + + /** + * Log a simple event (simplified interface) + * Maps to the full logAction method internally + */ + async logEvent(event: AuditEventInput): Promise { + const { + userId, + eventType, + action, + resourceType, + resourceId, + metadata = {}, + ipAddress, + userAgent, + } = event; + + // Map eventType string to AuditEventType enum + const mappedEventType = this.mapEventType(eventType); + + await this.logAction({ + eventType: mappedEventType, + action, + resourceType: (resourceType as CreateAuditLogInput['resourceType']) || 'system_config', + resourceId, + userId, + ipAddress, + userAgent, + metadata: { + ...metadata, + originalEventType: eventType, // Preserve the original event type string + }, + severity: 'info', + eventStatus: 'success', + }); + + logger.debug('[AuditService] Event logged:', { eventType, action, userId }); + } + + /** + * Get events by user with optional date range + */ + async getEventsByUser( + userId: string, + options: AuditQueryOptions = {} + ): Promise { + const { from, to, limit = 100 } = options; + + const logs = await this.getAuditLogs({ + userId, + dateFrom: from, + dateTo: to, + limit, + offset: 0, + }); + + return logs.map(this.transformToSimpleEvent); + } + + /** + * Get events by type with optional date range + */ + async getEventsByType( + eventType: string, + options: AuditQueryOptions = {} + ): Promise { + const { from, to, limit = 100 } = options; + + // If it's a simple string like 'auth.login', search in metadata + // Otherwise use the mapped event type + const mappedType = this.mapEventType(eventType); + + const logs = await this.getAuditLogs({ + eventType: mappedType, + dateFrom: from, + dateTo: to, + limit, + offset: 0, + }); + + // Filter by original event type if it was a custom string + const filtered = eventType.includes('.') + ? logs.filter((log) => log.metadata?.originalEventType === eventType) + : logs; + + return filtered.map(this.transformToSimpleEvent); + } + + /** + * Get most recent events (admin only) + */ + async getRecentEvents(limit = 50): Promise { + const logs = await this.getAuditLogs({ + limit, + offset: 0, + }); + + return logs.map(this.transformToSimpleEvent); + } + + /** + * Search events with advanced filters + */ + async searchEvents(query: AuditSearchQuery): Promise { + const { + userId, + eventType, + action, + resourceType, + resourceId, + ipAddress, + dateFrom, + dateTo, + searchText, + severity, + limit = 100, + offset = 0, + } = query; + + const conditions: string[] = []; + const params: (string | number | Date)[] = []; + let paramIndex = 1; + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (eventType) { + const mappedType = this.mapEventType(eventType); + conditions.push(`event_type = $${paramIndex++}`); + params.push(mappedType); + } + + if (action) { + conditions.push(`action ILIKE $${paramIndex++}`); + params.push(`%${action}%`); + } + + if (resourceType) { + conditions.push(`resource_type = $${paramIndex++}`); + params.push(resourceType); + } + + if (resourceId) { + conditions.push(`resource_id = $${paramIndex++}`); + params.push(resourceId); + } + + if (ipAddress) { + conditions.push(`ip_address = $${paramIndex++}`); + params.push(ipAddress); + } + + if (severity) { + conditions.push(`severity = $${paramIndex++}`); + params.push(severity); + } + + if (dateFrom) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(dateFrom); + } + + if (dateTo) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(dateTo); + } + + if (searchText) { + conditions.push(`( + action ILIKE $${paramIndex} OR + description ILIKE $${paramIndex} OR + resource_name ILIKE $${paramIndex} + )`); + params.push(`%${searchText}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await db.query( + `SELECT * FROM audit.audit_logs + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, limit, offset] + ); + + return result.rows.map((row) => this.transformToSimpleEvent(this.transformAuditLog(row))); + } + + /** + * Map common event type strings to AuditEventType enum + */ + private mapEventType(eventType: string): AuditEventType { + const mappings: Record = { + 'auth.login': 'login', + 'auth.logout': 'logout', + 'auth.password_change': 'update', + 'auth.2fa_enabled': 'config_change', + 'auth.2fa_disabled': 'config_change', + 'profile.update': 'update', + 'profile.avatar_change': 'update', + 'trading.order': 'create', + 'trading.position_open': 'create', + 'trading.position_close': 'update', + 'payment.initiated': 'create', + 'payment.completed': 'update', + 'payment.failed': 'update', + 'subscription.created': 'create', + 'subscription.cancelled': 'delete', + 'course.enrolled': 'create', + 'course.completed': 'update', + 'bot.started': 'create', + 'bot.stopped': 'update', + 'api.key_created': 'create', + 'api.key_revoked': 'delete', + }; + + // Return mapped value or default to the value if it's already an AuditEventType + return mappings[eventType] || (eventType as AuditEventType) || 'read'; + } + + /** + * Transform AuditLog to simplified AuditEvent + */ + private transformToSimpleEvent(log: AuditLog): AuditEvent { + return { + id: log.id, + userId: log.userId, + eventType: (log.metadata?.originalEventType as string) || log.eventType, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceId, + metadata: log.metadata, + ipAddress: log.ipAddress, + userAgent: log.userAgent, + createdAt: log.createdAt, + }; + } + /** * Transform database row to AuditLog */ diff --git a/src/modules/audit/types/audit.types.ts b/src/modules/audit/types/audit.types.ts index 4420303..79f865f 100644 --- a/src/modules/audit/types/audit.types.ts +++ b/src/modules/audit/types/audit.types.ts @@ -520,3 +520,94 @@ export interface DataAccessLogFilters { limit?: number; offset?: number; } + +// ============================================================================ +// Simplified Event Types (per task requirements) +// These are simplified aliases for common audit operations +// ============================================================================ + +/** + * Common event types for audit logging + */ +export type CommonEventType = + | 'auth.login' + | 'auth.logout' + | 'auth.password_change' + | 'auth.2fa_enabled' + | 'auth.2fa_disabled' + | 'profile.update' + | 'profile.avatar_change' + | 'trading.order' + | 'trading.position_open' + | 'trading.position_close' + | 'payment.initiated' + | 'payment.completed' + | 'payment.failed' + | 'subscription.created' + | 'subscription.cancelled' + | 'course.enrolled' + | 'course.completed' + | 'bot.started' + | 'bot.stopped' + | 'api.key_created' + | 'api.key_revoked'; + +/** + * Simplified AuditEvent interface (alias for AuditLog) + * Matches the structure requested in the task + */ +export interface AuditEvent { + id: string; + userId: string | null; + eventType: string; + action: string; + resourceType: string | null; + resourceId: string | null; + metadata: Record; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; +} + +/** + * Simplified input for logging events + * Matches the structure requested in the task + */ +export interface AuditEventInput { + userId?: string; + eventType: CommonEventType | string; + action: string; + resourceType?: string; + resourceId?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; +} + +/** + * Search query for advanced event filtering + */ +export interface AuditSearchQuery { + userId?: string; + eventType?: string; + action?: string; + resourceType?: string; + resourceId?: string; + ipAddress?: string; + dateFrom?: Date; + dateTo?: Date; + searchText?: string; + severity?: EventSeverity; + limit?: number; + offset?: number; +} + +/** + * Options for time-based queries + */ +export interface AuditQueryOptions { + from?: Date; + to?: Date; + limit?: number; + offset?: number; +} diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index 552d2dd..d0a929c 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -7,7 +7,7 @@ import { validationResult } from 'express-validator'; import { Request, Response, NextFunction } from 'express'; import { authRateLimiter, strictRateLimiter, refreshTokenRateLimiter } from '../../core/middleware/rate-limiter'; import { authenticate } from '../../core/middleware/auth.middleware'; -import * as authController from './controllers/auth.controller'; +import * as authController from './controllers'; import * as validators from './validators/auth.validators'; const router = Router(); @@ -271,6 +271,34 @@ router.post( authController.regenerateBackupCodes ); +/** + * GET /api/v1/auth/2fa/status + * Get 2FA status for authenticated user + */ +router.get('/2fa/status', authenticate, authController.get2FAStatus); + +/** + * POST /api/v1/auth/2fa/validate + * Validate 2FA code during login (second step of two-step login) + */ +router.post( + '/2fa/validate', + validators.totpCodeValidator, + validate, + authController.validate2FA +); + +/** + * POST /api/v1/auth/2fa/backup-codes/use + * Use a backup code during login + */ +router.post( + '/2fa/backup-codes/use', + validators.totpCodeValidator, + validate, + authController.useBackupCode +); + // ============================================================================ // Account Linking // ============================================================================ diff --git a/src/modules/auth/controllers/index.ts b/src/modules/auth/controllers/index.ts index cdb1dcf..f2c2855 100644 --- a/src/modules/auth/controllers/index.ts +++ b/src/modules/auth/controllers/index.ts @@ -44,6 +44,8 @@ export { disable2FA, regenerateBackupCodes, get2FAStatus, + validate2FA, + useBackupCode, } from './two-factor.controller'; // Token/Session Management diff --git a/src/modules/auth/controllers/two-factor.controller.ts b/src/modules/auth/controllers/two-factor.controller.ts index ff107c5..8ca667f 100644 --- a/src/modules/auth/controllers/two-factor.controller.ts +++ b/src/modules/auth/controllers/two-factor.controller.ts @@ -9,12 +9,18 @@ * - POST /auth/2fa/enable - Enable 2FA with verification code * - POST /auth/2fa/disable - Disable 2FA with verification code * - POST /auth/2fa/backup-codes - Regenerate backup codes + * - GET /auth/2fa/status - Get 2FA status + * - POST /auth/2fa/validate - Validate 2FA code during login + * - POST /auth/2fa/backup-codes/use - Use backup code during login * * @see EmailAuthController - Email/password authentication (handles 2FA during login) * @see TokenController - Token management */ import { Request, Response, NextFunction } from 'express'; import { twoFactorService } from '../services/twofa.service'; +import { tokenService } from '../services/token.service'; +import { db } from '../../../shared/database'; +import type { User } from '../types/auth.types'; /** * POST /auth/2fa/setup @@ -122,3 +128,147 @@ export const get2FAStatus = async (req: Request, res: Response, next: NextFuncti next(error); } }; + +/** + * POST /auth/2fa/validate + * + * Validate 2FA code during two-step login process. + * Called after initial login returns requiresTwoFactor: true. + * Requires pendingUserId from session/cookie or request body. + */ +export const validate2FA = async (req: Request, res: Response, next: NextFunction) => { + try { + const { code, userId: pendingUserId } = req.body; + + if (!pendingUserId) { + return res.status(400).json({ + success: false, + error: 'User ID is required for 2FA validation', + }); + } + + // Verify the 2FA code + const valid = await twoFactorService.verifyTOTP(pendingUserId, code); + + if (!valid) { + return res.status(401).json({ + success: false, + error: 'Invalid verification code', + }); + } + + // Get user to create session + const userResult = await db.query( + 'SELECT * FROM users WHERE id = $1', + [pendingUserId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + const user = userResult.rows[0]; + const userAgent = req.headers['user-agent']; + const ipAddress = req.ip || req.socket.remoteAddress; + + // Create session and tokens + const { tokens } = await tokenService.createSession( + user.id, + userAgent, + ipAddress + ); + + res.json({ + success: true, + data: { + user: { + id: user.id, + email: user.email, + role: user.role, + totpEnabled: user.totpEnabled, + }, + tokens, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * POST /auth/2fa/backup-codes/use + * + * Use a backup code during two-step login process. + * Similar to validate2FA but specifically for backup codes. + */ +export const useBackupCode = async (req: Request, res: Response, next: NextFunction) => { + try { + const { code, userId: pendingUserId } = req.body; + + if (!pendingUserId) { + return res.status(400).json({ + success: false, + error: 'User ID is required for backup code validation', + }); + } + + // verifyTOTP already handles backup codes internally + const valid = await twoFactorService.verifyTOTP(pendingUserId, code); + + if (!valid) { + return res.status(401).json({ + success: false, + error: 'Invalid backup code', + }); + } + + // Get user to create session + const userResult = await db.query( + 'SELECT * FROM users WHERE id = $1', + [pendingUserId] + ); + + if (userResult.rows.length === 0) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + const user = userResult.rows[0]; + const userAgent = req.headers['user-agent']; + const ipAddress = req.ip || req.socket.remoteAddress; + + // Create session and tokens + const { tokens } = await tokenService.createSession( + user.id, + userAgent, + ipAddress + ); + + // Get remaining backup codes count + const remainingCodes = await twoFactorService.getBackupCodesCount(pendingUserId); + + res.json({ + success: true, + data: { + user: { + id: user.id, + email: user.email, + role: user.role, + totpEnabled: user.totpEnabled, + }, + tokens, + backupCodesRemaining: remainingCodes, + }, + message: remainingCodes <= 3 + ? `Warning: Only ${remainingCodes} backup codes remaining` + : undefined, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/auth/types/auth.types.ts b/src/modules/auth/types/auth.types.ts index f673baf..b6d976a 100644 --- a/src/modules/auth/types/auth.types.ts +++ b/src/modules/auth/types/auth.types.ts @@ -161,6 +161,14 @@ export interface TwoFactorSetupResponse { backupCodes: string[]; } +export interface TwoFAStatus { + enabled: boolean; + method: '2fa_totp' | null; + backupCodesRemaining: number; + enabledAt?: Date; + lastUsedAt?: Date; +} + export interface RefreshTokenRequest { refreshToken: string; } diff --git a/src/modules/market-data/controllers/market-data.controller.ts b/src/modules/market-data/controllers/market-data.controller.ts index 1ef455f..425b31b 100644 --- a/src/modules/market-data/controllers/market-data.controller.ts +++ b/src/modules/market-data/controllers/market-data.controller.ts @@ -1,12 +1,14 @@ /** * Market Data Controller - * Handles OHLCV data endpoints + * Handles OHLCV data and Ticker endpoints */ import { Request, Response, NextFunction } from 'express'; import { marketDataService } from '../services/marketData.service'; -import { Timeframe } from '../types/market-data.types'; +import { Timeframe, AssetType } from '../types/market-data.types'; import { logger } from '../../../shared/utils/logger'; +import { isValidSymbol } from '../dto/get-ohlcv.dto'; +import { VALID_ASSET_TYPES, isValidAssetType } from '../dto/get-ticker.dto'; export async function getOHLCV(req: Request, res: Response, next: NextFunction): Promise { try { @@ -156,3 +158,176 @@ export async function healthCheck(req: Request, res: Response, next: NextFunctio next(error); } } + +// ============================================================================= +// Ticker Endpoints +// ============================================================================= + +/** + * GET /api/market-data/ticker/:symbol + * Get real-time ticker data for a specific symbol + */ +export async function getTicker(req: Request, res: Response, next: NextFunction): Promise { + try { + const { symbol } = req.params; + + if (!symbol || !isValidSymbol(symbol)) { + res.status(400).json({ + success: false, + error: { + message: 'Invalid or missing symbol parameter', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + const ticker = await marketDataService.getTicker(symbol); + + if (!ticker) { + res.status(404).json({ + success: false, + error: { + message: `Ticker not found: ${symbol}`, + code: 'NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: ticker, + }); + } catch (error) { + logger.error('Error in getTicker', { error: (error as Error).message }); + next(error); + } +} + +/** + * GET /api/market-data/tickers + * Get all tickers with optional filtering + * Query params: assetType, mlEnabled + */ +export async function getAllTickers(req: Request, res: Response, next: NextFunction): Promise { + try { + const { assetType, mlEnabled } = req.query; + + // Validate assetType if provided + if (assetType && !isValidAssetType(assetType as string)) { + res.status(400).json({ + success: false, + error: { + message: `Invalid asset type. Must be one of: ${VALID_ASSET_TYPES.join(', ')}`, + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + // Parse mlEnabled + let mlEnabledFilter: boolean | undefined; + if (mlEnabled !== undefined) { + mlEnabledFilter = mlEnabled === 'true' || mlEnabled === '1'; + } + + const tickers = await marketDataService.getAllTickers( + assetType as AssetType | undefined, + mlEnabledFilter + ); + + res.json({ + success: true, + data: tickers, + count: tickers.length, + }); + } catch (error) { + logger.error('Error in getAllTickers', { error: (error as Error).message }); + next(error); + } +} + +/** + * GET /api/market-data/price/:symbol + * Get the latest price for a symbol + */ +export async function getLatestPrice(req: Request, res: Response, next: NextFunction): Promise { + try { + const { symbol } = req.params; + + if (!symbol || !isValidSymbol(symbol)) { + res.status(400).json({ + success: false, + error: { + message: 'Invalid or missing symbol parameter', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + const price = await marketDataService.getLatestPrice(symbol); + + if (!price) { + res.status(404).json({ + success: false, + error: { + message: `Price not found for symbol: ${symbol}`, + code: 'NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: price, + }); + } catch (error) { + logger.error('Error in getLatestPrice', { error: (error as Error).message }); + next(error); + } +} + +/** + * GET /api/market-data/ticker-info/:symbol + * Get ticker metadata/info from catalog + */ +export async function getTickerInfo(req: Request, res: Response, next: NextFunction): Promise { + try { + const { symbol } = req.params; + + if (!symbol || !isValidSymbol(symbol)) { + res.status(400).json({ + success: false, + error: { + message: 'Invalid or missing symbol parameter', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + const info = await marketDataService.getTickerInfo(symbol); + + if (!info) { + res.status(404).json({ + success: false, + error: { + message: `Ticker info not found: ${symbol}`, + code: 'NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: info, + }); + } catch (error) { + logger.error('Error in getTickerInfo', { error: (error as Error).message }); + next(error); + } +} diff --git a/src/modules/market-data/dto/get-ohlcv.dto.ts b/src/modules/market-data/dto/get-ohlcv.dto.ts new file mode 100644 index 0000000..7becbf1 --- /dev/null +++ b/src/modules/market-data/dto/get-ohlcv.dto.ts @@ -0,0 +1,136 @@ +/** + * DTO for OHLCV data requests + * Validates query parameters for OHLCV endpoints + */ + +import { Timeframe } from '../types/market-data.types'; + +// ============================================================================= +// Valid Timeframes +// ============================================================================= + +export const VALID_TIMEFRAMES: Timeframe[] = ['5m', '15m', '1h', '4h', '1d']; + +// ============================================================================= +// Request DTOs +// ============================================================================= + +export interface GetOHLCVParams { + symbol: string; + timeframe: Timeframe; +} + +export interface GetOHLCVQuery { + limit?: number; +} + +export interface GetHistoricalDataParams { + symbol: string; +} + +export interface GetHistoricalDataQuery { + timeframe: Timeframe; + from: string; + to: string; +} + +// ============================================================================= +// Validation Functions +// ============================================================================= + +export function isValidTimeframe(timeframe: string): timeframe is Timeframe { + return VALID_TIMEFRAMES.includes(timeframe as Timeframe); +} + +export function isValidSymbol(symbol: string): boolean { + if (!symbol || typeof symbol !== 'string') { + return false; + } + // Symbol should be alphanumeric, 3-20 characters + return /^[A-Z0-9]{3,20}$/i.test(symbol); +} + +export function isValidLimit(limit: unknown): limit is number { + if (limit === undefined || limit === null) { + return true; // Optional field + } + const num = Number(limit); + return !isNaN(num) && num > 0 && num <= 1000; +} + +export function isValidDateString(dateStr: string): boolean { + if (!dateStr || typeof dateStr !== 'string') { + return false; + } + const date = new Date(dateStr); + return !isNaN(date.getTime()); +} + +// ============================================================================= +// Validation Result Types +// ============================================================================= + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +export function validateGetOHLCVRequest( + params: Partial, + query: Partial +): ValidationResult { + const errors: string[] = []; + + if (!params.symbol || !isValidSymbol(params.symbol)) { + errors.push('Invalid or missing symbol parameter'); + } + + if (!params.timeframe || !isValidTimeframe(params.timeframe)) { + errors.push(`Invalid timeframe. Must be one of: ${VALID_TIMEFRAMES.join(', ')}`); + } + + if (query.limit !== undefined && !isValidLimit(query.limit)) { + errors.push('Invalid limit. Must be a positive number between 1 and 1000'); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +export function validateGetHistoricalDataRequest( + params: Partial, + query: Partial +): ValidationResult { + const errors: string[] = []; + + if (!params.symbol || !isValidSymbol(params.symbol)) { + errors.push('Invalid or missing symbol parameter'); + } + + if (!query.timeframe || !isValidTimeframe(query.timeframe)) { + errors.push(`Invalid timeframe. Must be one of: ${VALID_TIMEFRAMES.join(', ')}`); + } + + if (!query.from || !isValidDateString(query.from)) { + errors.push('Invalid or missing from date'); + } + + if (!query.to || !isValidDateString(query.to)) { + errors.push('Invalid or missing to date'); + } + + if (query.from && query.to && isValidDateString(query.from) && isValidDateString(query.to)) { + const fromDate = new Date(query.from); + const toDate = new Date(query.to); + if (fromDate >= toDate) { + errors.push('from date must be before to date'); + } + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/src/modules/market-data/dto/get-ticker.dto.ts b/src/modules/market-data/dto/get-ticker.dto.ts new file mode 100644 index 0000000..2856334 --- /dev/null +++ b/src/modules/market-data/dto/get-ticker.dto.ts @@ -0,0 +1,68 @@ +/** + * DTO for Ticker data requests + * Validates query parameters for ticker endpoints + */ + +// ============================================================================= +// Request DTOs +// ============================================================================= + +export interface GetTickerParams { + symbol: string; +} + +export interface GetLatestPriceParams { + symbol: string; +} + +export interface GetAllTickersQuery { + assetType?: string; + mlEnabled?: boolean; +} + +// ============================================================================= +// Valid Asset Types +// ============================================================================= + +export const VALID_ASSET_TYPES = ['forex', 'crypto', 'commodity', 'index', 'stock'] as const; + +export type ValidAssetType = (typeof VALID_ASSET_TYPES)[number]; + +// ============================================================================= +// Validation Functions +// ============================================================================= + +export function isValidAssetType(assetType: string): assetType is ValidAssetType { + return VALID_ASSET_TYPES.includes(assetType as ValidAssetType); +} + +// Import isValidSymbol and ValidationResult from ohlcv dto to avoid duplication +import { isValidSymbol, ValidationResult } from './get-ohlcv.dto'; + +export function validateGetTickerRequest(params: Partial): ValidationResult { + const errors: string[] = []; + + if (!params.symbol || !isValidSymbol(params.symbol)) { + errors.push('Invalid or missing symbol parameter'); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +export function validateGetAllTickersRequest( + query: Partial +): ValidationResult { + const errors: string[] = []; + + if (query.assetType && !isValidAssetType(query.assetType)) { + errors.push(`Invalid asset type. Must be one of: ${VALID_ASSET_TYPES.join(', ')}`); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/src/modules/market-data/dto/index.ts b/src/modules/market-data/dto/index.ts new file mode 100644 index 0000000..13f5863 --- /dev/null +++ b/src/modules/market-data/dto/index.ts @@ -0,0 +1,40 @@ +/** + * Market Data DTOs + * Export all DTOs for the market-data module + */ + +// OHLCV DTOs - Constants and Functions +export { + VALID_TIMEFRAMES, + isValidTimeframe, + isValidSymbol, + isValidLimit, + isValidDateString, + validateGetOHLCVRequest, + validateGetHistoricalDataRequest, +} from './get-ohlcv.dto'; + +// OHLCV DTOs - Types +export type { + GetOHLCVParams, + GetOHLCVQuery, + GetHistoricalDataParams, + GetHistoricalDataQuery, + ValidationResult, +} from './get-ohlcv.dto'; + +// Ticker DTOs - Constants and Functions +export { + VALID_ASSET_TYPES, + isValidAssetType, + validateGetTickerRequest, + validateGetAllTickersRequest, +} from './get-ticker.dto'; + +// Ticker DTOs - Types +export type { + GetTickerParams, + GetLatestPriceParams, + GetAllTickersQuery, + ValidAssetType, +} from './get-ticker.dto'; diff --git a/src/modules/market-data/index.ts b/src/modules/market-data/index.ts index ed37727..8abdc02 100644 --- a/src/modules/market-data/index.ts +++ b/src/modules/market-data/index.ts @@ -1,8 +1,25 @@ /** * Market Data Module - * Exports market data service, routes, and types + * Provides OHLCV data and real-time ticker information + * + * Features: + * - OHLCV candles (5m, 15m, 1h, 4h, 1d timeframes) + * - Real-time ticker data with 24h statistics + * - Latest price lookup + * - Redis caching with configurable TTLs + * - PostgreSQL data storage + * + * @module market-data */ +// Service export { marketDataService } from './services/marketData.service'; + +// Routes export { marketDataRouter } from './market-data.routes'; + +// Types export * from './types/market-data.types'; + +// DTOs +export * from './dto'; diff --git a/src/modules/market-data/market-data.routes.ts b/src/modules/market-data/market-data.routes.ts index 69912fd..fd601e9 100644 --- a/src/modules/market-data/market-data.routes.ts +++ b/src/modules/market-data/market-data.routes.ts @@ -1,6 +1,17 @@ /** * Market Data Routes - * Defines REST endpoints for OHLCV market data + * Defines REST endpoints for OHLCV market data and Tickers + * + * Endpoints: + * - GET /api/market-data/health - Service health check + * - GET /api/market-data/symbols - List available symbols + * - GET /api/market-data/ohlcv/:symbol/:timeframe - Get OHLCV candles + * - GET /api/market-data/ohlcv/:symbol - Get OHLCV (default 5m timeframe) + * - GET /api/market-data/historical/:symbol - Get historical data range + * - GET /api/market-data/ticker/:symbol - Get real-time ticker + * - GET /api/market-data/tickers - Get all tickers + * - GET /api/market-data/price/:symbol - Get latest price + * - GET /api/market-data/ticker-info/:symbol - Get ticker metadata */ import { Router } from 'express'; @@ -9,16 +20,88 @@ import { getHistoricalData, getAvailableSymbols, healthCheck, + getTicker, + getAllTickers, + getLatestPrice, + getTickerInfo, } from './controllers/market-data.controller'; const router = Router(); +// ============================================================================= +// Health & Info +// ============================================================================= + +/** + * GET /api/market-data/health + * Service health check + */ router.get('/health', healthCheck); +/** + * GET /api/market-data/symbols + * Get list of available symbols + */ router.get('/symbols', getAvailableSymbols); +// ============================================================================= +// OHLCV Endpoints +// ============================================================================= + +/** + * GET /api/market-data/ohlcv/:symbol/:timeframe + * Get OHLCV candles for a symbol and timeframe + * Query params: limit (optional, default 100, max 1000) + */ router.get('/ohlcv/:symbol/:timeframe', getOHLCV); +/** + * GET /api/market-data/ohlcv/:symbol + * Get OHLCV candles with default 5m timeframe + * Query params: limit (optional, default 100, max 1000) + */ +router.get('/ohlcv/:symbol', (req, res, next) => { + // Cast to allow adding timeframe param + (req.params as Record).timeframe = '5m'; + return getOHLCV(req, res, next); +}); + +/** + * GET /api/market-data/historical/:symbol + * Get historical OHLCV data for a date range + * Query params: timeframe, from (ISO date), to (ISO date) + */ router.get('/historical/:symbol', getHistoricalData); +// ============================================================================= +// Ticker Endpoints +// ============================================================================= + +/** + * GET /api/market-data/ticker/:symbol + * Get real-time ticker data for a symbol + * Returns: bid, ask, last, volume24h, change24h, high24h, low24h + */ +router.get('/ticker/:symbol', getTicker); + +/** + * GET /api/market-data/tickers + * Get all tickers + * Query params: assetType (forex|crypto|commodity|index|stock), mlEnabled (true|false) + */ +router.get('/tickers', getAllTickers); + +/** + * GET /api/market-data/price/:symbol + * Get the latest price for a symbol + * Returns: symbol, price, timestamp + */ +router.get('/price/:symbol', getLatestPrice); + +/** + * GET /api/market-data/ticker-info/:symbol + * Get ticker metadata (name, asset type, currencies, etc.) + */ +router.get('/ticker-info/:symbol', getTickerInfo); + export { router as marketDataRouter }; diff --git a/src/modules/market-data/services/marketData.service.ts b/src/modules/market-data/services/marketData.service.ts index 07795e4..6bf226c 100644 --- a/src/modules/market-data/services/marketData.service.ts +++ b/src/modules/market-data/services/marketData.service.ts @@ -10,10 +10,12 @@ import { logger } from '../../../shared/utils/logger'; import { OHLCV, Timeframe, - CandleQueryOptions, - HistoricalDataOptions, OhlcvDataRow, TickerRow, + Ticker, + TickerInfo, + LatestPrice, + AssetType, } from '../types/market-data.types'; // ============================================================================= @@ -28,6 +30,13 @@ const CACHE_TTL_BY_TIMEFRAME: Record = { '1d': 3600, // 1 hour cache }; +// ============================================================================= +// Cache TTL for Ticker Data (shorter for real-time feel) +// ============================================================================= + +const CACHE_TTL_TICKER = 5; // 5 seconds for ticker data +const CACHE_TTL_PRICE = 5; // 5 seconds for latest price + // ============================================================================= // Timeframe Minutes Mapping // ============================================================================= @@ -420,6 +429,320 @@ class MarketDataService { }; } } + + // =========================================================================== + // Ticker Methods - Real-time market data + // =========================================================================== + + /** + * Get ticker data for a specific symbol + * Calculates real-time stats from OHLCV data + * Cache TTL: 5 seconds + */ + async getTicker(symbol: string): Promise { + const cacheKey = `market-data:ticker:${symbol.toUpperCase()}`; + + try { + const redisClient = await redis.getClient(); + const cached = await redisClient.get(cacheKey); + + if (cached) { + logger.debug('Ticker cache hit', { symbol }); + return JSON.parse(cached); + } + } catch (error) { + logger.warn('Redis get failed', { error: (error as Error).message }); + } + + const ticker = await this.calculateTicker(symbol); + + if (ticker) { + try { + const redisClient = await redis.getClient(); + await redisClient.setex(cacheKey, CACHE_TTL_TICKER, JSON.stringify(ticker)); + } catch (error) { + logger.warn('Redis set failed', { error: (error as Error).message }); + } + } + + return ticker; + } + + /** + * Get all tickers with optional filtering + * Cache TTL: 5 seconds + */ + async getAllTickers(assetType?: AssetType, mlEnabled?: boolean): Promise { + const filterKey = `${assetType || 'all'}:${mlEnabled ?? 'all'}`; + const cacheKey = `market-data:tickers:${filterKey}`; + + try { + const redisClient = await redis.getClient(); + const cached = await redisClient.get(cacheKey); + + if (cached) { + logger.debug('All tickers cache hit', { assetType, mlEnabled }); + return JSON.parse(cached); + } + } catch (error) { + logger.warn('Redis get failed', { error: (error as Error).message }); + } + + // Build query for ticker catalog + let tickerQuery = 'SELECT symbol FROM market_data.tickers WHERE is_active = true'; + const params: (string | boolean)[] = []; + + if (assetType) { + params.push(assetType); + tickerQuery += ` AND asset_type = $${params.length}`; + } + + if (mlEnabled !== undefined) { + params.push(mlEnabled); + tickerQuery += ` AND is_ml_enabled = $${params.length}`; + } + + tickerQuery += ' ORDER BY symbol'; + + const tickerResult = await db.query<{ symbol: string }>(tickerQuery, params); + const symbols = tickerResult.rows.map((row) => row.symbol); + + // Calculate ticker data for each symbol + const tickers: Ticker[] = []; + for (const sym of symbols) { + const ticker = await this.calculateTicker(sym); + if (ticker) { + tickers.push(ticker); + } + } + + try { + const redisClient = await redis.getClient(); + await redisClient.setex(cacheKey, CACHE_TTL_TICKER, JSON.stringify(tickers)); + } catch (error) { + logger.warn('Redis set failed', { error: (error as Error).message }); + } + + return tickers; + } + + /** + * Get the latest price for a symbol + * Cache TTL: 5 seconds + */ + async getLatestPrice(symbol: string): Promise { + const cacheKey = `market-data:price:${symbol.toUpperCase()}`; + + try { + const redisClient = await redis.getClient(); + const cached = await redisClient.get(cacheKey); + + if (cached) { + logger.debug('Price cache hit', { symbol }); + return JSON.parse(cached); + } + } catch (error) { + logger.warn('Redis get failed', { error: (error as Error).message }); + } + + const price = await this.fetchLatestPrice(symbol); + + if (price) { + try { + const redisClient = await redis.getClient(); + await redisClient.setex(cacheKey, CACHE_TTL_PRICE, JSON.stringify(price)); + } catch (error) { + logger.warn('Redis set failed', { error: (error as Error).message }); + } + } + + return price; + } + + /** + * Get ticker info from catalog (metadata, not real-time data) + */ + async getTickerInfo(symbol: string): Promise { + const result = await db.query( + `SELECT * FROM market_data.tickers WHERE symbol = $1 AND is_active = true`, + [symbol.toUpperCase()] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + symbol: row.symbol, + name: row.name, + assetType: row.asset_type, + baseCurrency: row.base_currency, + quoteCurrency: row.quote_currency, + isMlEnabled: row.is_ml_enabled, + supportedTimeframes: row.supported_timeframes, + polygonTicker: row.polygon_ticker, + isActive: row.is_active, + }; + } + + // =========================================================================== + // Private Helper Methods for Ticker Calculations + // =========================================================================== + + /** + * Calculate ticker data from OHLCV candles + * Uses latest candle for current price and 24h of data for statistics + */ + private async calculateTicker(symbol: string): Promise { + const upperSymbol = symbol.toUpperCase(); + + // Get ticker ID + const tickerResult = await db.query( + 'SELECT id FROM market_data.tickers WHERE symbol = $1 AND is_active = true', + [upperSymbol] + ); + + if (tickerResult.rows.length === 0) { + logger.warn('Ticker not found for calculation', { symbol }); + return null; + } + + const tickerId = tickerResult.rows[0].id; + + // Fetch the latest candle for current price + const latestQuery = ` + SELECT timestamp, open, high, low, close, volume, vwap + FROM market_data.ohlcv_5m + WHERE ticker_id = $1 + ORDER BY timestamp DESC + LIMIT 1 + `; + + const latestResult = await db.query(latestQuery, [tickerId]); + + if (latestResult.rows.length === 0) { + logger.warn('No OHLCV data found for ticker', { symbol }); + return null; + } + + const latestCandle = latestResult.rows[0]; + const currentPrice = parseFloat(latestCandle.close); + + // Fetch 24h of data for statistics (288 candles = 24h of 5m candles) + const stats24hQuery = ` + SELECT + MIN(low) as low_24h, + MAX(high) as high_24h, + SUM(volume) as volume_24h + FROM market_data.ohlcv_5m + WHERE ticker_id = $1 + AND timestamp >= NOW() - INTERVAL '24 hours' + `; + + const stats24hResult = await db.query<{ + low_24h: string; + high_24h: string; + volume_24h: string; + }>(stats24hQuery, [tickerId]); + + // Get opening price from 24h ago + const open24hQuery = ` + SELECT open, vwap + FROM market_data.ohlcv_5m + WHERE ticker_id = $1 + AND timestamp >= NOW() - INTERVAL '24 hours' + ORDER BY timestamp ASC + LIMIT 1 + `; + + const open24hResult = await db.query<{ open: string; vwap: string }>(open24hQuery, [tickerId]); + + const stats = stats24hResult.rows[0]; + const open24hRow = open24hResult.rows[0]; + + const open24h = open24hRow ? parseFloat(open24hRow.open) : currentPrice; + const high24h = stats?.high_24h ? parseFloat(stats.high_24h) : currentPrice; + const low24h = stats?.low_24h ? parseFloat(stats.low_24h) : currentPrice; + const volume24h = stats?.volume_24h ? parseFloat(stats.volume_24h) : 0; + + const change24h = currentPrice - open24h; + const changePercent24h = open24h !== 0 ? (change24h / open24h) * 100 : 0; + + // Calculate bid/ask spread (simulated from current price - typically 0.01-0.05%) + const spreadPercent = 0.0002; // 0.02% spread + const halfSpread = currentPrice * spreadPercent / 2; + const bid = currentPrice - halfSpread; + const ask = currentPrice + halfSpread; + + // Calculate 24h VWAP + const vwap24hQuery = ` + SELECT + SUM((high + low + close) / 3 * volume) / NULLIF(SUM(volume), 0) as vwap_24h + FROM market_data.ohlcv_5m + WHERE ticker_id = $1 + AND timestamp >= NOW() - INTERVAL '24 hours' + `; + + const vwap24hResult = await db.query<{ vwap_24h: string | null }>(vwap24hQuery, [tickerId]); + const vwap24h = vwap24hResult.rows[0]?.vwap_24h + ? parseFloat(vwap24hResult.rows[0].vwap_24h) + : undefined; + + return { + symbol: upperSymbol, + bid: Math.round(bid * 100000000) / 100000000, + ask: Math.round(ask * 100000000) / 100000000, + last: currentPrice, + volume24h, + change24h: Math.round(change24h * 100000000) / 100000000, + changePercent24h: Math.round(changePercent24h * 100) / 100, + high24h, + low24h, + open24h, + vwap24h, + timestamp: new Date(latestCandle.timestamp), + }; + } + + /** + * Fetch just the latest price for a symbol + */ + private async fetchLatestPrice(symbol: string): Promise { + const upperSymbol = symbol.toUpperCase(); + + const tickerResult = await db.query( + 'SELECT id FROM market_data.tickers WHERE symbol = $1 AND is_active = true', + [upperSymbol] + ); + + if (tickerResult.rows.length === 0) { + logger.warn('Ticker not found for price lookup', { symbol }); + return null; + } + + const tickerId = tickerResult.rows[0].id; + + const query = ` + SELECT close, timestamp + FROM market_data.ohlcv_5m + WHERE ticker_id = $1 + ORDER BY timestamp DESC + LIMIT 1 + `; + + const result = await db.query<{ close: string; timestamp: Date }>(query, [tickerId]); + + if (result.rows.length === 0) { + return null; + } + + return { + symbol: upperSymbol, + price: parseFloat(result.rows[0].close), + timestamp: new Date(result.rows[0].timestamp), + }; + } } export const marketDataService = new MarketDataService(); diff --git a/src/modules/market-data/types/market-data.types.ts b/src/modules/market-data/types/market-data.types.ts index 10fad80..1142df9 100644 --- a/src/modules/market-data/types/market-data.types.ts +++ b/src/modules/market-data/types/market-data.types.ts @@ -125,3 +125,50 @@ export interface OhlcvStagingRow { * @deprecated Use Ohlcv5mRow instead */ export type OhlcvDataRow = Ohlcv5mRow; + +// ============================================================================= +// Ticker Types - Real-time market data derived from OHLCV +// ============================================================================= + +/** + * Ticker data representing current market state for a symbol + * Derived from latest OHLCV candles and 24h calculations + */ +export interface Ticker { + symbol: string; + bid: number; + ask: number; + last: number; + volume24h: number; + change24h: number; + changePercent24h: number; + high24h: number; + low24h: number; + open24h: number; + vwap24h?: number; + timestamp: Date; +} + +/** + * Simplified ticker info from the catalog + */ +export interface TickerInfo { + symbol: string; + name: string; + assetType: AssetType; + baseCurrency: string; + quoteCurrency: string; + isMlEnabled: boolean; + supportedTimeframes: string[]; + polygonTicker: string | null; + isActive: boolean; +} + +/** + * Latest price response + */ +export interface LatestPrice { + symbol: string; + price: number; + timestamp: Date; +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts index 62773e5..145092f 100644 --- a/src/modules/notifications/index.ts +++ b/src/modules/notifications/index.ts @@ -1,7 +1,34 @@ /** * Notifications Module - * Exports notification service, routes, and types + * Unified notification system supporting push, email, in-app, and WebSocket delivery + * + * Features: + * - Multi-channel delivery (push, email, in-app, SMS) + * - User preferences management + * - Quiet hours support + * - Firebase Cloud Messaging integration + * - WebSocket real-time notifications + * - Email templates for each notification type + * + * @module notifications */ -export * from './services/notification.service'; -export * from './notification.routes'; +// Service +export { notificationService } from './services/notification.service'; + +// Routes +export { notificationRouter } from './notification.routes'; + +// Types +export * from './types/notifications.types'; + +// Re-export service types for backwards compatibility +export type { + NotificationType, + NotificationPriority, + DeliveryChannel, + NotificationPayload, + Notification, + UserNotificationPreferences, + SendNotificationOptions, +} from './services/notification.service'; diff --git a/src/modules/notifications/types/notifications.types.ts b/src/modules/notifications/types/notifications.types.ts new file mode 100644 index 0000000..26d0f1f --- /dev/null +++ b/src/modules/notifications/types/notifications.types.ts @@ -0,0 +1,269 @@ +/** + * Notifications Module Types + * Types and interfaces for the notification system + * Aligned with DDL: apps/database/ddl/schemas/auth/tables/ + */ + +// ============================================================================= +// Enums and Constants +// ============================================================================= + +/** + * Types of notifications supported by the platform + */ +export type NotificationType = + | 'alert_triggered' + | 'trade_executed' + | 'deposit_confirmed' + | 'withdrawal_completed' + | 'distribution_received' + | 'system_announcement' + | 'security_alert' + | 'account_update'; + +/** + * Priority levels for notifications + */ +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +/** + * Channels through which notifications can be delivered + */ +export type DeliveryChannel = 'push' | 'email' | 'in_app' | 'sms'; + +/** + * Icon types for visual notification display + */ +export type NotificationIconType = 'success' | 'warning' | 'error' | 'info'; + +/** + * Platform types for push tokens + */ +export type PushTokenPlatform = 'web' | 'ios' | 'android'; + +// ============================================================================= +// API/Business Types +// ============================================================================= + +/** + * Notification entity - represents a notification in the system + */ +export interface Notification { + id: string; + userId: string; + type: NotificationType; + title: string; + message: string; + priority: NotificationPriority; + data?: Record; + actionUrl?: string; + iconType: string; + channels: DeliveryChannel[]; + isRead: boolean; + readAt?: Date; + createdAt: Date; +} + +/** + * Input for creating a new notification + */ +export interface CreateNotificationInput { + userId: string; + type: NotificationType; + title: string; + message: string; + priority?: NotificationPriority; + data?: Record; + actionUrl?: string; + iconType?: NotificationIconType; +} + +/** + * Payload for sending notifications + */ +export interface NotificationPayload { + title: string; + message: string; + type: NotificationType; + priority?: NotificationPriority; + data?: Record; + actionUrl?: string; + iconType?: NotificationIconType; +} + +/** + * User notification preferences + */ +export interface NotificationPreferences { + userId: string; + email: boolean; + push: boolean; + inApp: boolean; + sms?: boolean; + tradingAlerts: boolean; + newsAlerts: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + disabledTypes: NotificationType[]; +} + +/** + * Extended user notification preferences (internal format) + */ +export interface UserNotificationPreferences { + userId: string; + emailEnabled: boolean; + pushEnabled: boolean; + inAppEnabled: boolean; + smsEnabled: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + disabledTypes: NotificationType[]; +} + +/** + * Options for sending notifications + */ +export interface SendNotificationOptions { + channels?: DeliveryChannel[]; + priority?: NotificationPriority; + skipPreferences?: boolean; +} + +/** + * Options for querying notifications + */ +export interface GetNotificationsOptions { + limit?: number; + offset?: number; + unreadOnly?: boolean; +} + +// ============================================================================= +// Database Row Types - Aligned with auth schema DDL +// ============================================================================= + +/** + * Notification database row - maps to auth.notifications table + * DDL: apps/database/ddl/schemas/auth/tables/notifications.sql + */ +export interface NotificationRow { + id: string; + user_id: string; + type: string; + title: string; + message: string; + priority: string; + data: string | null; + action_url: string | null; + icon_type: string; + channels: string[]; + is_read: boolean; + read_at: string | null; + created_at: string; +} + +/** + * Push token database row - maps to auth.user_push_tokens table + * DDL: apps/database/ddl/schemas/auth/tables/user_push_tokens.sql + */ +export interface PushTokenRow { + id: string; + user_id: string; + token: string; + platform: string; + device_info: string | null; + is_active: boolean; + created_at: string; + updated_at: string; +} + +// ============================================================================= +// Alert Integration Types +// ============================================================================= + +/** + * Alert data for price alert notifications + */ +export interface AlertNotificationData { + symbol: string; + condition: string; + targetPrice: number; + currentPrice: number; + note?: string; +} + +/** + * Trade data for trade execution notifications + */ +export interface TradeNotificationData { + symbol: string; + side: 'buy' | 'sell'; + quantity: number; + price: number; + total: number; +} + +/** + * Distribution data for investment return notifications + */ +export interface DistributionNotificationData { + productName: string; + amount: number; + accountNumber: string; + newBalance: number; +} + +// ============================================================================= +// Push Notification Types +// ============================================================================= + +/** + * Push notification registration input + */ +export interface RegisterPushTokenInput { + token: string; + platform: PushTokenPlatform; + deviceInfo?: Record; +} + +/** + * Push notification send result + */ +export interface PushSendResult { + successCount: number; + failureCount: number; + failedTokens: string[]; +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +/** + * Response for getting notifications + */ +export interface GetNotificationsResponse { + success: boolean; + data: Notification[]; +} + +/** + * Response for unread count + */ +export interface UnreadCountResponse { + success: boolean; + data: { + count: number; + }; +} + +/** + * Response for mark all as read + */ +export interface MarkAllAsReadResponse { + success: boolean; + data: { + markedCount: number; + }; +} diff --git a/src/modules/trading/controllers/bots.controller.ts b/src/modules/trading/controllers/bots.controller.ts new file mode 100644 index 0000000..b9886c1 --- /dev/null +++ b/src/modules/trading/controllers/bots.controller.ts @@ -0,0 +1,488 @@ +/** + * Bots Controller + * =============== + * Handles trading bots endpoints (Atlas, Orion, Nova) + * All routes require authentication + */ + +import { Request, Response, NextFunction } from 'express'; +import { botsService } from '../services/bots.service'; +import { BotStatus, BotType, Timeframe } from '../types/order.types'; +import { StrategyType } from '../types/entity.types'; + +// ============================================================================ +// Types +// ============================================================================ + +type AuthRequest = Request; + +// ============================================================================ +// Bot CRUD Controllers +// ============================================================================ + +/** + * GET /api/v1/trading/bots + * Get all bots for the authenticated user + */ +export async function getBots(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { status, botType, strategyType, limit, offset } = req.query; + + const bots = await botsService.getBots(userId, { + status: status as BotStatus | undefined, + botType: botType as BotType | undefined, + strategyType: strategyType as StrategyType | undefined, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + + res.json({ + success: true, + data: bots, + meta: { count: bots.length }, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/trading/bots/:botId + * Get a specific bot by ID + */ +export async function getBotById(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const bot = await botsService.getBotById(userId, botId); + + if (!bot) { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: bot, + }); + } catch (error) { + next(error); + } +} + +/** + * POST /api/v1/trading/bots + * Create a new trading bot + */ +export async function createBot(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { name, botType, symbols, timeframe, initialCapital, strategyType, strategyConfig, + maxPositionSizePct, maxDailyLossPct, maxDrawdownPct } = req.body; + + if (!name || !symbols || !initialCapital) { + res.status(400).json({ + success: false, + error: { message: 'Missing required fields: name, symbols, initialCapital', code: 'VALIDATION_ERROR' }, + }); + return; + } + + if (!Array.isArray(symbols) || symbols.length === 0) { + res.status(400).json({ + success: false, + error: { message: 'symbols must be a non-empty array', code: 'VALIDATION_ERROR' }, + }); + return; + } + + if (initialCapital < 100) { + res.status(400).json({ + success: false, + error: { message: 'Initial capital must be at least 100', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const bot = await botsService.createBot(userId, { + name, + botType: botType as BotType, + symbols, + timeframe: timeframe as Timeframe, + initialCapital, + strategyType: strategyType as StrategyType, + strategyConfig, + maxPositionSizePct, + maxDailyLossPct, + maxDrawdownPct, + }); + + res.status(201).json({ + success: true, + data: bot, + message: 'Bot created successfully', + }); + } catch (error) { + next(error); + } +} + +/** + * PUT /api/v1/trading/bots/:botId + * Update bot configuration + */ +export async function updateBot(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const { name, status, symbols, timeframe, strategyType, strategyConfig, + maxPositionSizePct, maxDailyLossPct, maxDrawdownPct } = req.body; + + const bot = await botsService.updateBot(userId, botId, { + name, + status: status as BotStatus, + symbols, + timeframe: timeframe as Timeframe, + strategyType: strategyType as StrategyType, + strategyConfig, + maxPositionSizePct, + maxDailyLossPct, + maxDrawdownPct, + }); + + if (!bot) { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: bot, + message: 'Bot updated successfully', + }); + } catch (error) { + next(error); + } +} + +/** + * DELETE /api/v1/trading/bots/:botId + * Delete a bot (must be stopped first) + */ +export async function deleteBot(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + + await botsService.deleteBot(userId, botId); + + res.json({ + success: true, + message: 'Bot deleted successfully', + }); + } catch (error) { + if ((error as Error).message === 'Bot not found') { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + if ((error as Error).message.includes('Cannot delete an active bot')) { + res.status(400).json({ + success: false, + error: { message: (error as Error).message, code: 'BOT_ACTIVE' }, + }); + return; + } + next(error); + } +} + +// ============================================================================ +// Bot Control Controllers +// ============================================================================ + +/** + * POST /api/v1/trading/bots/:botId/start + * Start a bot + */ +export async function startBot(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const bot = await botsService.startBot(userId, botId); + + res.json({ + success: true, + data: bot, + message: 'Bot started successfully', + }); + } catch (error) { + if ((error as Error).message === 'Bot not found') { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + if ((error as Error).message.includes('already active')) { + res.status(400).json({ + success: false, + error: { message: (error as Error).message, code: 'BOT_ALREADY_ACTIVE' }, + }); + return; + } + next(error); + } +} + +/** + * POST /api/v1/trading/bots/:botId/stop + * Stop a bot + */ +export async function stopBot(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const bot = await botsService.stopBot(userId, botId); + + res.json({ + success: true, + data: bot, + message: 'Bot stopped successfully', + }); + } catch (error) { + if ((error as Error).message === 'Bot not found') { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + if ((error as Error).message.includes('already stopped')) { + res.status(400).json({ + success: false, + error: { message: (error as Error).message, code: 'BOT_ALREADY_STOPPED' }, + }); + return; + } + next(error); + } +} + +// ============================================================================ +// Performance & Executions Controllers +// ============================================================================ + +/** + * GET /api/v1/trading/bots/:botId/performance + * Get bot performance metrics + */ +export async function getBotPerformance(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const performance = await botsService.getBotPerformance(userId, botId); + + res.json({ + success: true, + data: performance, + }); + } catch (error) { + if ((error as Error).message === 'Bot not found') { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + next(error); + } +} + +/** + * GET /api/v1/trading/bots/:botId/executions + * Get bot execution history + */ +export async function getBotExecutions(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { botId } = req.params; + const { limit, offset } = req.query; + + const executions = await botsService.getBotExecutions(userId, botId, { + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + + res.json({ + success: true, + data: executions, + meta: { count: executions.length }, + }); + } catch (error) { + if ((error as Error).message === 'Bot not found') { + res.status(404).json({ + success: false, + error: { message: 'Bot not found', code: 'NOT_FOUND' }, + }); + return; + } + next(error); + } +} + +// ============================================================================ +// Templates Controller +// ============================================================================ + +/** + * GET /api/v1/trading/bots/templates + * Get available bot templates (Atlas, Orion, Nova) + */ +export async function getTemplates(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const templates = botsService.getTemplates(); + + res.json({ + success: true, + data: templates, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/trading/bots/templates/:type + * Get a specific bot template + */ +export async function getTemplateByType(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { type } = req.params; + const validTypes = ['atlas', 'orion', 'nova', 'custom']; + + if (!validTypes.includes(type)) { + res.status(400).json({ + success: false, + error: { message: 'Invalid template type. Must be: atlas, orion, nova, or custom', code: 'INVALID_TYPE' }, + }); + return; + } + + const template = botsService.getTemplate(type as StrategyType); + + if (!template) { + res.status(404).json({ + success: false, + error: { message: 'Template not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: template, + }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/trading/services/bots.service.ts b/src/modules/trading/services/bots.service.ts new file mode 100644 index 0000000..a3ac898 --- /dev/null +++ b/src/modules/trading/services/bots.service.ts @@ -0,0 +1,508 @@ +/** + * Trading Bots Service + * ==================== + * Service for managing trading bots (Atlas, Orion, Nova) + * Aligned with trading.bots DDL table + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import { + TradingBot, + CreateTradingBotDto, + UpdateTradingBotDto, + TradingBotFilters, + StrategyType, +} from '../types/entity.types'; +import { + BotStatus, + BotType, + Timeframe, + TradingBotStrategyConfig, +} from '../types/order.types'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BotPerformance { + totalTrades: number; + winRate: number; + profitLoss: number; + sharpeRatio: number; + maxDrawdown: number; + averageWin: number; + averageLoss: number; + profitFactor: number; +} + +export interface BotExecution { + id: string; + botId: string; + action: 'buy' | 'sell'; + symbol: string; + price: number; + quantity: number; + timestamp: Date; + result: 'success' | 'failed' | 'pending'; + pnl?: number; + metadata?: Record; +} + +export interface PaginationOptions { + limit?: number; + offset?: number; +} + +export interface BotTemplate { + type: StrategyType; + name: string; + description: string; + defaultConfig: TradingBotStrategyConfig; + riskLevel: 'low' | 'medium' | 'high'; + recommendedTimeframes: Timeframe[]; + recommendedSymbols: string[]; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function mapBot(row: Record): TradingBot { + return { + id: row.id as string, + userId: row.user_id as string, + name: row.name as string, + botType: row.bot_type as BotType, + status: row.status as BotStatus, + symbols: row.symbols as string[], + timeframe: row.timeframe as Timeframe, + initialCapital: parseFloat(row.initial_capital as string), + currentCapital: parseFloat(row.current_capital as string), + maxPositionSizePct: parseFloat(row.max_position_size_pct as string), + maxDailyLossPct: parseFloat(row.max_daily_loss_pct as string), + maxDrawdownPct: parseFloat(row.max_drawdown_pct as string), + strategyType: row.strategy_type as StrategyType | undefined, + strategyConfig: (row.strategy_config as TradingBotStrategyConfig) || {}, + totalTrades: row.total_trades as number, + winningTrades: row.winning_trades as number, + totalProfitLoss: parseFloat(row.total_profit_loss as string), + winRate: parseFloat(row.win_rate as string), + startedAt: row.started_at ? new Date(row.started_at as string) : undefined, + stoppedAt: row.stopped_at ? new Date(row.stopped_at as string) : undefined, + lastTradeAt: row.last_trade_at ? new Date(row.last_trade_at as string) : undefined, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +function mapExecution(row: Record): BotExecution { + return { + id: row.id as string, + botId: row.bot_id as string, + action: row.side as 'buy' | 'sell', + symbol: row.symbol as string, + price: parseFloat(row.price as string), + quantity: parseFloat(row.quantity as string), + timestamp: new Date(row.created_at as string), + result: row.status === 'filled' ? 'success' : row.status === 'rejected' ? 'failed' : 'pending', + pnl: row.realized_pnl ? parseFloat(row.realized_pnl as string) : undefined, + metadata: row.metadata as Record | undefined, + }; +} + +// ============================================================================ +// Bot Templates +// ============================================================================ + +const BOT_TEMPLATES: BotTemplate[] = [ + { + type: 'atlas', + name: 'Atlas - Trend Following', + description: 'Follows market trends using moving averages and momentum indicators. Best for trending markets.', + riskLevel: 'medium', + recommendedTimeframes: ['1h', '4h', '1d'], + recommendedSymbols: ['BTCUSDT', 'ETHUSDT', 'BNBUSDT'], + defaultConfig: { + strategy_name: 'Atlas Trend Following', + entry_rules: { + ma_fast: 20, + ma_slow: 50, + require_trend_confirmation: true, + min_trend_strength: 0.6, + }, + exit_rules: { + trailing_stop: true, + trailing_stop_percent: 2.5, + take_profit_percent: 5.0, + }, + risk_management: { + max_position_size: 0.1, + stop_loss_percent: 2.0, + take_profit_percent: 5.0, + max_daily_loss: 0.03, + }, + indicators: [ + { name: 'EMA', params: { period: 20 } }, + { name: 'EMA', params: { period: 50 } }, + { name: 'RSI', params: { period: 14 } }, + { name: 'ADX', params: { period: 14 } }, + ], + }, + }, + { + type: 'orion', + name: 'Orion - Mean Reversion', + description: 'Trades price reversions to the mean using Bollinger Bands and RSI. Best for ranging markets.', + riskLevel: 'low', + recommendedTimeframes: ['15m', '1h', '4h'], + recommendedSymbols: ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'], + defaultConfig: { + strategy_name: 'Orion Mean Reversion', + entry_rules: { + bollinger_period: 20, + bollinger_std: 2, + rsi_oversold: 30, + rsi_overbought: 70, + require_volume_confirmation: true, + }, + exit_rules: { + exit_at_mean: true, + max_hold_periods: 24, + stop_loss_percent: 1.5, + }, + risk_management: { + max_position_size: 0.08, + stop_loss_percent: 1.5, + take_profit_percent: 3.0, + max_daily_loss: 0.02, + }, + indicators: [ + { name: 'Bollinger', params: { period: 20, stdDev: 2 } }, + { name: 'RSI', params: { period: 14 } }, + { name: 'VWAP', params: {} }, + ], + }, + }, + { + type: 'nova', + name: 'Nova - Breakout', + description: 'Captures breakouts from consolidation patterns. High risk, high reward strategy.', + riskLevel: 'high', + recommendedTimeframes: ['5m', '15m', '1h'], + recommendedSymbols: ['BTCUSDT', 'ETHUSDT', 'XRPUSDT', 'DOGEUSDT'], + defaultConfig: { + strategy_name: 'Nova Breakout', + entry_rules: { + lookback_periods: 20, + breakout_threshold: 0.02, + volume_spike_multiplier: 1.5, + require_momentum_confirmation: true, + }, + exit_rules: { + trailing_stop: true, + trailing_stop_percent: 1.5, + time_based_exit_hours: 4, + }, + risk_management: { + max_position_size: 0.12, + stop_loss_percent: 1.0, + take_profit_percent: 4.0, + max_daily_loss: 0.04, + }, + indicators: [ + { name: 'ATR', params: { period: 14 } }, + { name: 'Volume', params: { period: 20 } }, + { name: 'MACD', params: { fast: 12, slow: 26, signal: 9 } }, + ], + }, + }, +]; + +// ============================================================================ +// Bots Service +// ============================================================================ + +class BotsService { + // ========================================================================== + // CRUD Operations + // ========================================================================== + + /** + * Get all bots for a user + */ + async getBots(userId: string, filters?: TradingBotFilters): Promise { + const conditions: string[] = ['user_id = $1']; + const params: (string | number)[] = [userId]; + let paramIndex = 2; + + if (filters?.status) { + conditions.push(`status = $${paramIndex++}`); + params.push(filters.status); + } + if (filters?.botType) { + conditions.push(`bot_type = $${paramIndex++}`); + params.push(filters.botType); + } + if (filters?.strategyType) { + conditions.push(`strategy_type = $${paramIndex++}`); + params.push(filters.strategyType); + } + + let query = `SELECT * FROM trading.bots WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC`; + + if (filters?.limit) { + query += ` LIMIT $${paramIndex++}`; + params.push(filters.limit); + } + if (filters?.offset) { + query += ` OFFSET $${paramIndex}`; + params.push(filters.offset); + } + + const result = await db.query>(query, params); + return result.rows.map(mapBot); + } + + /** + * Get a single bot by ID + */ + async getBotById(userId: string, botId: string): Promise { + const result = await db.query>( + 'SELECT * FROM trading.bots WHERE id = $1 AND user_id = $2', + [botId, userId] + ); + if (result.rows.length === 0) return null; + return mapBot(result.rows[0]); + } + + /** + * Create a new bot + */ + async createBot(userId: string, input: CreateTradingBotDto): Promise { + const template = BOT_TEMPLATES.find(t => t.type === input.strategyType); + const strategyConfig = input.strategyConfig || template?.defaultConfig || {}; + + const result = await db.query>( + `INSERT INTO trading.bots ( + user_id, name, bot_type, symbols, timeframe, + initial_capital, current_capital, + max_position_size_pct, max_daily_loss_pct, max_drawdown_pct, + strategy_type, strategy_config + ) VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + userId, + input.name, + input.botType || 'paper', + input.symbols, + input.timeframe || '1h', + input.initialCapital, + input.maxPositionSizePct || 10.0, + input.maxDailyLossPct || 5.0, + input.maxDrawdownPct || 20.0, + input.strategyType || null, + JSON.stringify(strategyConfig), + ] + ); + + logger.info('[BotsService] Bot created', { userId, botId: result.rows[0].id, name: input.name }); + return mapBot(result.rows[0]); + } + + /** + * Update bot configuration + */ + async updateBot(userId: string, botId: string, updates: UpdateTradingBotDto): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) return null; + + const fields: string[] = ['updated_at = NOW()']; + const params: (string | number | null | object)[] = []; + let paramIndex = 1; + + if (updates.name !== undefined) { + fields.push(`name = $${paramIndex++}`); + params.push(updates.name); + } + if (updates.status !== undefined) { + fields.push(`status = $${paramIndex++}`); + params.push(updates.status); + } + if (updates.symbols !== undefined) { + fields.push(`symbols = $${paramIndex++}`); + params.push(updates.symbols as unknown as string); + } + if (updates.timeframe !== undefined) { + fields.push(`timeframe = $${paramIndex++}`); + params.push(updates.timeframe); + } + if (updates.maxPositionSizePct !== undefined) { + fields.push(`max_position_size_pct = $${paramIndex++}`); + params.push(updates.maxPositionSizePct); + } + if (updates.maxDailyLossPct !== undefined) { + fields.push(`max_daily_loss_pct = $${paramIndex++}`); + params.push(updates.maxDailyLossPct); + } + if (updates.maxDrawdownPct !== undefined) { + fields.push(`max_drawdown_pct = $${paramIndex++}`); + params.push(updates.maxDrawdownPct); + } + if (updates.strategyType !== undefined) { + fields.push(`strategy_type = $${paramIndex++}`); + params.push(updates.strategyType); + } + if (updates.strategyConfig !== undefined) { + fields.push(`strategy_config = $${paramIndex++}`); + params.push(JSON.stringify(updates.strategyConfig)); + } + + params.push(botId, userId); + + const result = await db.query>( + `UPDATE trading.bots SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`, + params + ); + + if (result.rows.length === 0) return null; + logger.info('[BotsService] Bot updated', { userId, botId }); + return mapBot(result.rows[0]); + } + + /** + * Delete a bot + */ + async deleteBot(userId: string, botId: string): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) throw new Error('Bot not found'); + if (bot.status === 'active') throw new Error('Cannot delete an active bot. Stop it first.'); + + await db.query('DELETE FROM trading.bots WHERE id = $1 AND user_id = $2', [botId, userId]); + logger.info('[BotsService] Bot deleted', { userId, botId }); + } + + // ========================================================================== + // Bot Control + // ========================================================================== + + /** + * Start a bot + */ + async startBot(userId: string, botId: string): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) throw new Error('Bot not found'); + if (bot.status === 'active') throw new Error('Bot is already active'); + + const result = await db.query>( + `UPDATE trading.bots SET status = 'active', started_at = NOW(), updated_at = NOW() + WHERE id = $1 AND user_id = $2 RETURNING *`, + [botId, userId] + ); + + logger.info('[BotsService] Bot started', { userId, botId }); + return mapBot(result.rows[0]); + } + + /** + * Stop a bot + */ + async stopBot(userId: string, botId: string): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) throw new Error('Bot not found'); + if (bot.status === 'stopped') throw new Error('Bot is already stopped'); + + const result = await db.query>( + `UPDATE trading.bots SET status = 'stopped', stopped_at = NOW(), updated_at = NOW() + WHERE id = $1 AND user_id = $2 RETURNING *`, + [botId, userId] + ); + + logger.info('[BotsService] Bot stopped', { userId, botId }); + return mapBot(result.rows[0]); + } + + // ========================================================================== + // Performance & Executions + // ========================================================================== + + /** + * Get bot performance metrics + */ + async getBotPerformance(userId: string, botId: string): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) throw new Error('Bot not found'); + + const metricsResult = await db.query>( + `SELECT + COALESCE(SUM(total_trades), 0) as total_trades, + COALESCE(AVG(win_rate), 0) as win_rate, + COALESCE(SUM(net_profit), 0) as net_profit, + COALESCE(AVG(sharpe_ratio), 0) as sharpe_ratio, + COALESCE(MAX(max_drawdown), 0) as max_drawdown, + COALESCE(AVG(avg_win), 0) as avg_win, + COALESCE(AVG(avg_loss), 0) as avg_loss, + COALESCE(AVG(profit_factor), 1) as profit_factor + FROM trading.trading_metrics WHERE bot_id = $1`, + [botId] + ); + + const metrics = metricsResult.rows[0]; + + return { + totalTrades: bot.totalTrades || parseInt(metrics.total_trades, 10), + winRate: bot.winRate || parseFloat(metrics.win_rate), + profitLoss: bot.totalProfitLoss || parseFloat(metrics.net_profit), + sharpeRatio: parseFloat(metrics.sharpe_ratio) || 0, + maxDrawdown: parseFloat(metrics.max_drawdown) || 0, + averageWin: parseFloat(metrics.avg_win) || 0, + averageLoss: parseFloat(metrics.avg_loss) || 0, + profitFactor: parseFloat(metrics.profit_factor) || 0, + }; + } + + /** + * Get bot executions (trades history) + */ + async getBotExecutions( + userId: string, + botId: string, + options?: PaginationOptions + ): Promise { + const bot = await this.getBotById(userId, botId); + if (!bot) throw new Error('Bot not found'); + + const limit = options?.limit || 50; + const offset = options?.offset || 0; + + const result = await db.query>( + `SELECT o.* FROM trading.orders o + WHERE o.bot_id = $1 + ORDER BY o.created_at DESC + LIMIT $2 OFFSET $3`, + [botId, limit, offset] + ); + + return result.rows.map(mapExecution); + } + + // ========================================================================== + // Templates + // ========================================================================== + + /** + * Get available bot templates + */ + getTemplates(): BotTemplate[] { + return BOT_TEMPLATES; + } + + /** + * Get a specific template by type + */ + getTemplate(type: StrategyType): BotTemplate | undefined { + return BOT_TEMPLATES.find(t => t.type === type); + } +} + +export const botsService = new BotsService(); diff --git a/src/modules/trading/trading.routes.ts b/src/modules/trading/trading.routes.ts index a32c4ab..eae75bd 100644 --- a/src/modules/trading/trading.routes.ts +++ b/src/modules/trading/trading.routes.ts @@ -1,6 +1,6 @@ /** * Trading Routes - * Market data, paper trading, and watchlist endpoints + * Market data, paper trading, watchlist, and bots endpoints */ import { Router, RequestHandler } from 'express'; @@ -9,6 +9,7 @@ import * as watchlistController from './controllers/watchlist.controller'; import * as indicatorsController from './controllers/indicators.controller'; import * as alertsController from './controllers/alerts.controller'; import * as exportController from './controllers/export.controller'; +import * as botsController from './controllers/bots.controller'; import { requireAuth } from '../../core/guards/auth.guard'; const router = Router(); @@ -397,4 +398,80 @@ router.post('/alerts/:alertId/enable', authHandler(requireAuth), authHandler(ale */ router.post('/alerts/:alertId/disable', authHandler(requireAuth), authHandler(alertsController.disableAlert)); +// ============================================================================ +// Trading Bots Routes (Authenticated) +// Atlas (trend following), Orion (mean reversion), Nova (breakout) +// ============================================================================ + +/** + * GET /api/v1/trading/bots/templates + * Get available bot templates (Atlas, Orion, Nova) + * IMPORTANT: This route must be before /bots/:botId to avoid param conflict + */ +router.get('/bots/templates', requireAuth, authHandler(botsController.getTemplates)); + +/** + * GET /api/v1/trading/bots/templates/:type + * Get a specific bot template + */ +router.get('/bots/templates/:type', requireAuth, authHandler(botsController.getTemplateByType)); + +/** + * GET /api/v1/trading/bots + * Get all bots for the authenticated user + * Query: status, botType, strategyType, limit, offset + */ +router.get('/bots', requireAuth, authHandler(botsController.getBots)); + +/** + * GET /api/v1/trading/bots/:botId + * Get a specific bot by ID + */ +router.get('/bots/:botId', requireAuth, authHandler(botsController.getBotById)); + +/** + * POST /api/v1/trading/bots + * Create a new trading bot + * Body: { name, botType?, symbols, timeframe?, initialCapital, strategyType?, strategyConfig? } + */ +router.post('/bots', requireAuth, authHandler(botsController.createBot)); + +/** + * PUT /api/v1/trading/bots/:botId + * Update bot configuration + * Body: { name?, status?, symbols?, timeframe?, strategyType?, strategyConfig?, maxPositionSizePct?, maxDailyLossPct?, maxDrawdownPct? } + */ +router.put('/bots/:botId', requireAuth, authHandler(botsController.updateBot)); + +/** + * DELETE /api/v1/trading/bots/:botId + * Delete a bot (must be stopped first) + */ +router.delete('/bots/:botId', requireAuth, authHandler(botsController.deleteBot)); + +/** + * POST /api/v1/trading/bots/:botId/start + * Start a bot + */ +router.post('/bots/:botId/start', requireAuth, authHandler(botsController.startBot)); + +/** + * POST /api/v1/trading/bots/:botId/stop + * Stop a bot + */ +router.post('/bots/:botId/stop', requireAuth, authHandler(botsController.stopBot)); + +/** + * GET /api/v1/trading/bots/:botId/performance + * Get bot performance metrics + */ +router.get('/bots/:botId/performance', requireAuth, authHandler(botsController.getBotPerformance)); + +/** + * GET /api/v1/trading/bots/:botId/executions + * Get bot execution history + * Query: limit, offset + */ +router.get('/bots/:botId/executions', requireAuth, authHandler(botsController.getBotExecutions)); + export { router as tradingRouter };