diff --git a/src/config/index.ts b/src/config/index.ts index d2be9fa..6b51a01 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -68,6 +68,16 @@ export const config = { timeout: parseInt(process.env.ML_ENGINE_TIMEOUT || '5000', 10), }, + llmAgent: { + url: process.env.LLM_AGENT_URL || 'http://localhost:3085', + timeout: parseInt(process.env.LLM_AGENT_TIMEOUT || '30000', 10), + }, + + dataService: { + url: process.env.DATA_SERVICE_URL || 'http://localhost:3084', + timeout: parseInt(process.env.DATA_SERVICE_TIMEOUT || '10000', 10), + }, + firebase: { serviceAccountKey: process.env.FIREBASE_SERVICE_ACCOUNT_KEY || '', projectId: process.env.FIREBASE_PROJECT_ID || '', diff --git a/src/index.ts b/src/index.ts index f2a5f0d..b576ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { notificationRouter } from './modules/notifications/notification.routes. import { marketDataRouter } from './modules/market-data/index.js'; import { currencyRouter } from './modules/currency/index.js'; import { auditRouter } from './modules/audit/index.js'; +import { proxyRoutes } from './modules/proxy/index.js'; // Service clients for health checks import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js'; @@ -160,6 +161,7 @@ apiRouter.use('/notifications', notificationRouter); apiRouter.use('/market-data', marketDataRouter); apiRouter.use('/currency', currencyRouter); apiRouter.use('/audit', auditRouter); +apiRouter.use('/proxy', proxyRoutes); // ARCH-001: Gateway to Python services // Mount API router app.use('/api/v1', apiRouter); diff --git a/src/modules/audit/audit.routes.ts b/src/modules/audit/audit.routes.ts new file mode 100644 index 0000000..09b8967 --- /dev/null +++ b/src/modules/audit/audit.routes.ts @@ -0,0 +1,97 @@ +/** + * Audit Routes + * API endpoints for audit logs, security events, and compliance tracking + */ + +import { Router, RequestHandler } from 'express'; +import * as auditController from './controllers/audit.controller'; +import { requireAuth, requireAdmin } from '../../core/guards/auth.guard'; + +const router = Router(); + +// Type cast helper for authenticated routes +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; + +/** + * GET /api/v1/audit/my-activity + * Get current user's own audit logs + * Authentication required + */ +router.get('/my-activity', requireAuth, authHandler(auditController.getMyActivity)); + +/** + * GET /api/v1/audit/logs + * Get audit logs with filters + * Admin only + * Query params: userId, eventType, resourceType, resourceId, severity, dateFrom, dateTo, limit, offset + */ +router.get('/logs', requireAuth, requireAdmin, authHandler(auditController.getAuditLogs)); + +/** + * GET /api/v1/audit/security-events + * Get security events with filters + * Admin only + * Query params: userId, category, severity, isBlocked, requiresReview, dateFrom, dateTo, limit, offset + */ +router.get( + '/security-events', + requireAuth, + requireAdmin, + authHandler(auditController.getSecurityEvents) +); + +/** + * GET /api/v1/audit/compliance + * Get all compliance logs + * Admin only + * Query params: userId, regulation, complianceStatus, riskLevel, remediationRequired, dateFrom, dateTo, limit, offset + */ +router.get( + '/compliance', + requireAuth, + requireAdmin, + authHandler(auditController.getComplianceLogs) +); + +/** + * GET /api/v1/audit/compliance/:userId + * Get compliance logs for a specific user + * Admin only + * Query params: regulation, complianceStatus, riskLevel, remediationRequired, dateFrom, dateTo, limit, offset + */ +router.get( + '/compliance/:userId', + requireAuth, + requireAdmin, + authHandler(auditController.getUserComplianceLogs) +); + +/** + * GET /api/v1/audit/stats + * Get audit statistics + * Admin only + * Query params: dateFrom, dateTo + */ +router.get('/stats', requireAuth, requireAdmin, authHandler(auditController.getAuditStats)); + +/** + * POST /api/v1/audit/log + * Create an audit log entry + * Admin only (for internal/system use) + */ +router.post('/log', requireAuth, requireAdmin, authHandler(auditController.createAuditLog)); + +/** + * POST /api/v1/audit/security-event + * Log a security event + * Admin only (for internal/system use) + */ +router.post( + '/security-event', + requireAuth, + requireAdmin, + authHandler(auditController.createSecurityEvent) +); + +export { router as auditRouter }; diff --git a/src/modules/audit/controllers/audit.controller.ts b/src/modules/audit/controllers/audit.controller.ts new file mode 100644 index 0000000..80460af --- /dev/null +++ b/src/modules/audit/controllers/audit.controller.ts @@ -0,0 +1,328 @@ +/** + * Audit Controller + * Handles audit-related HTTP requests + */ + +import { Response } from 'express'; +import { AuthenticatedRequest } from '../../../core/guards/auth.guard'; +import { auditService } from '../services/audit.service'; +import { logger } from '../../../shared/utils/logger'; +import type { + AuditLogFilters, + SecurityEventFilters, + ComplianceLogFilters, +} from '../types/audit.types'; + +/** + * GET /api/v1/audit/logs + * Get audit logs (admin only) + */ +export async function getAuditLogs( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { + userId, + eventType, + resourceType, + resourceId, + severity, + dateFrom, + dateTo, + limit, + offset, + } = req.query; + + const filters: AuditLogFilters = { + userId: userId as string | undefined, + eventType: eventType as AuditLogFilters['eventType'], + resourceType: resourceType as AuditLogFilters['resourceType'], + resourceId: resourceId as string | undefined, + severity: severity as AuditLogFilters['severity'], + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }; + + const logs = await auditService.getAuditLogs(filters); + + res.json({ + success: true, + data: logs, + }); + } catch (error) { + logger.error('[AuditController] Failed to get audit logs:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve audit logs', + }); + } +} + +/** + * GET /api/v1/audit/security-events + * Get security events (admin only) + */ +export async function getSecurityEvents( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { + userId, + category, + severity, + isBlocked, + requiresReview, + dateFrom, + dateTo, + limit, + offset, + } = req.query; + + const filters: SecurityEventFilters = { + userId: userId as string | undefined, + category: category as SecurityEventFilters['category'], + severity: severity as SecurityEventFilters['severity'], + isBlocked: isBlocked === 'true' ? true : isBlocked === 'false' ? false : undefined, + requiresReview: requiresReview === 'true' ? true : requiresReview === 'false' ? false : undefined, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }; + + const events = await auditService.getSecurityEvents(filters); + + res.json({ + success: true, + data: events, + }); + } catch (error) { + logger.error('[AuditController] Failed to get security events:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve security events', + }); + } +} + +/** + * GET /api/v1/audit/compliance/:userId + * Get compliance logs for a user (admin only) + */ +export async function getUserComplianceLogs( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { userId } = req.params; + const { + regulation, + complianceStatus, + riskLevel, + remediationRequired, + dateFrom, + dateTo, + limit, + offset, + } = req.query; + + const filters: ComplianceLogFilters = { + userId, + regulation: regulation as ComplianceLogFilters['regulation'], + complianceStatus: complianceStatus as ComplianceLogFilters['complianceStatus'], + riskLevel: riskLevel as ComplianceLogFilters['riskLevel'], + remediationRequired: remediationRequired === 'true' ? true : remediationRequired === 'false' ? false : undefined, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }; + + const logs = await auditService.getComplianceLogs(filters); + + res.json({ + success: true, + data: logs, + }); + } catch (error) { + logger.error('[AuditController] Failed to get compliance logs:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve compliance logs', + }); + } +} + +/** + * GET /api/v1/audit/compliance + * Get all compliance logs (admin only) + */ +export async function getComplianceLogs( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { + userId, + regulation, + complianceStatus, + riskLevel, + remediationRequired, + dateFrom, + dateTo, + limit, + offset, + } = req.query; + + const filters: ComplianceLogFilters = { + userId: userId as string | undefined, + regulation: regulation as ComplianceLogFilters['regulation'], + complianceStatus: complianceStatus as ComplianceLogFilters['complianceStatus'], + riskLevel: riskLevel as ComplianceLogFilters['riskLevel'], + remediationRequired: remediationRequired === 'true' ? true : remediationRequired === 'false' ? false : undefined, + dateFrom: dateFrom ? new Date(dateFrom as string) : undefined, + dateTo: dateTo ? new Date(dateTo as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }; + + const logs = await auditService.getComplianceLogs(filters); + + res.json({ + success: true, + data: logs, + }); + } catch (error) { + logger.error('[AuditController] Failed to get compliance logs:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve compliance logs', + }); + } +} + +/** + * GET /api/v1/audit/my-activity + * Get current user's audit logs + */ +export async function getMyActivity( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const userId = req.user!.id; + const { limit, offset } = req.query; + + const logs = await auditService.getUserAuditLogs( + userId, + limit ? parseInt(limit as string, 10) : 100, + offset ? parseInt(offset as string, 10) : 0 + ); + + res.json({ + success: true, + data: logs, + }); + } catch (error) { + logger.error('[AuditController] Failed to get user activity:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve your activity logs', + }); + } +} + +/** + * GET /api/v1/audit/stats + * Get audit statistics (admin only) + */ +export async function getAuditStats( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { dateFrom, dateTo } = req.query; + + const stats = await auditService.getAuditStats( + dateFrom ? new Date(dateFrom as string) : undefined, + dateTo ? new Date(dateTo as string) : undefined + ); + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + logger.error('[AuditController] Failed to get audit stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to retrieve audit statistics', + }); + } +} + +/** + * POST /api/v1/audit/log + * Create an audit log entry (internal use) + */ +export async function createAuditLog( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const data = req.body; + + const log = await auditService.logAction({ + ...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, + data: log, + }); + } catch (error) { + logger.error('[AuditController] Failed to create audit log:', error); + res.status(500).json({ + success: false, + error: 'Failed to create audit log', + }); + } +} + +/** + * POST /api/v1/audit/security-event + * Log a security event (internal use) + */ +export async function createSecurityEvent( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const data = req.body; + + const event = await auditService.logSecurityEvent({ + ...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, + data: event, + }); + } catch (error) { + logger.error('[AuditController] Failed to create security event:', error); + res.status(500).json({ + success: false, + error: 'Failed to create security event', + }); + } +} diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..cf13f85 --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,8 @@ +/** + * Audit Module + * Exports for audit logging, security events, and compliance tracking + */ + +export { auditRouter } from './audit.routes'; +export { auditService } from './services/audit.service'; +export * from './types/audit.types'; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts new file mode 100644 index 0000000..a6a7925 --- /dev/null +++ b/src/modules/audit/services/audit.service.ts @@ -0,0 +1,653 @@ +/** + * Audit Service + * Handles audit logging, security events, and compliance tracking + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import type { + AuditLog, + CreateAuditLogInput, + AuditLogFilters, + SecurityEvent, + CreateSecurityEventInput, + SecurityEventFilters, + ComplianceLog, + CreateComplianceLogInput, + ComplianceLogFilters, + AuditStats, + AuditEventType, + EventSeverity, +} from '../types/audit.types'; + +interface AuditLogRow { + id: string; + event_type: string; + event_status: string; + severity: string; + user_id: string | null; + session_id: string | null; + ip_address: string | null; + user_agent: string | null; + resource_type: string; + resource_id: string | null; + resource_name: string | null; + action: string; + description: string | null; + old_values: string | null; + new_values: string | null; + metadata: string; + request_id: string | null; + correlation_id: string | null; + service_name: string | null; + created_at: string; +} + +interface SecurityEventRow { + id: string; + category: string; + severity: string; + event_status: string; + user_id: string | null; + ip_address: string; + user_agent: string | null; + geo_location: string | null; + event_code: string; + event_name: string; + description: string | null; + request_path: string | null; + request_method: string | null; + response_code: number | null; + risk_score: string | null; + is_blocked: boolean; + block_reason: string | null; + requires_review: boolean; + reviewed_by: string | null; + reviewed_at: string | null; + review_notes: string | null; + raw_data: string; + created_at: string; +} + +interface ComplianceLogRow { + id: string; + regulation: string; + requirement: string; + event_type: string; + event_description: string; + user_id: string | null; + system_initiated: boolean; + compliance_status: string; + risk_level: string | null; + evidence: string | null; + remediation_required: boolean; + remediation_deadline: string | null; + remediation_notes: string | null; + reviewed_by: string | null; + reviewed_at: string | null; + metadata: string; + created_at: string; + updated_at: string; +} + +class AuditService { + /** + * Get audit logs with optional filters + */ + async getAuditLogs(filters: AuditLogFilters = {}): Promise { + const { + userId, + eventType, + resourceType, + resourceId, + severity, + dateFrom, + dateTo, + limit = 100, + offset = 0, + } = filters; + + const conditions: string[] = []; + const params: (string | number | Date)[] = []; + let paramIndex = 1; + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (eventType) { + conditions.push(`event_type = $${paramIndex++}`); + params.push(eventType); + } + + if (resourceType) { + conditions.push(`resource_type = $${paramIndex++}`); + params.push(resourceType); + } + + if (resourceId) { + conditions.push(`resource_id = $${paramIndex++}`); + params.push(resourceId); + } + + 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); + } + + 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(this.transformAuditLog); + } + + /** + * Get audit logs for a specific user + */ + async getUserAuditLogs(userId: string, limit = 100, offset = 0): Promise { + return this.getAuditLogs({ userId, limit, offset }); + } + + /** + * Create a new audit log entry + */ + async logAction(data: CreateAuditLogInput): Promise { + const { + eventType, + eventStatus = 'success', + severity = 'info', + userId, + sessionId, + ipAddress, + userAgent, + resourceType, + resourceId, + resourceName, + action, + description, + oldValues, + newValues, + metadata = {}, + requestId, + correlationId, + serviceName, + } = data; + + const result = await db.query( + `INSERT INTO audit.audit_logs ( + event_type, event_status, severity, + user_id, session_id, ip_address, user_agent, + resource_type, resource_id, resource_name, + action, description, old_values, new_values, + metadata, request_id, correlation_id, service_name + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18 + ) RETURNING *`, + [ + eventType, + eventStatus, + severity, + userId || null, + sessionId || null, + ipAddress || null, + userAgent || null, + resourceType, + resourceId || null, + resourceName || null, + action, + description || null, + oldValues ? JSON.stringify(oldValues) : null, + newValues ? JSON.stringify(newValues) : null, + JSON.stringify(metadata), + requestId || null, + correlationId || null, + serviceName || null, + ] + ); + + logger.info('[AuditService] Action logged:', { + eventType, + action, + userId, + resourceType, + resourceId, + }); + + return this.transformAuditLog(result.rows[0]); + } + + /** + * Get security events with optional filters + */ + async getSecurityEvents(filters: SecurityEventFilters = {}): Promise { + const { + userId, + category, + severity, + isBlocked, + requiresReview, + dateFrom, + dateTo, + limit = 100, + offset = 0, + } = filters; + + const conditions: string[] = []; + const params: (string | number | boolean | Date)[] = []; + let paramIndex = 1; + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (category) { + conditions.push(`category = $${paramIndex++}`); + params.push(category); + } + + if (severity) { + conditions.push(`severity = $${paramIndex++}`); + params.push(severity); + } + + if (isBlocked !== undefined) { + conditions.push(`is_blocked = $${paramIndex++}`); + params.push(isBlocked); + } + + if (requiresReview !== undefined) { + conditions.push(`requires_review = $${paramIndex++}`); + params.push(requiresReview); + } + + if (dateFrom) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(dateFrom); + } + + if (dateTo) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(dateTo); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await db.query( + `SELECT * FROM audit.security_events + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, limit, offset] + ); + + return result.rows.map(this.transformSecurityEvent); + } + + /** + * Create a new security event + */ + async logSecurityEvent(data: CreateSecurityEventInput): Promise { + const { + category, + severity, + eventStatus = 'success', + userId, + ipAddress, + userAgent, + geoLocation, + eventCode, + eventName, + description, + requestPath, + requestMethod, + responseCode, + riskScore, + isBlocked = false, + blockReason, + requiresReview = false, + rawData = {}, + } = data; + + const result = await db.query( + `INSERT INTO audit.security_events ( + category, severity, event_status, + user_id, ip_address, user_agent, geo_location, + event_code, event_name, description, + request_path, request_method, response_code, + risk_score, is_blocked, block_reason, + requires_review, raw_data + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18 + ) RETURNING *`, + [ + category, + severity, + eventStatus, + userId || null, + ipAddress, + userAgent || null, + geoLocation ? JSON.stringify(geoLocation) : null, + eventCode, + eventName, + description || null, + requestPath || null, + requestMethod || null, + responseCode || null, + riskScore || null, + isBlocked, + blockReason || null, + requiresReview, + JSON.stringify(rawData), + ] + ); + + logger.warn('[AuditService] Security event logged:', { + category, + severity, + eventCode, + eventName, + userId, + isBlocked, + }); + + return this.transformSecurityEvent(result.rows[0]); + } + + /** + * Get compliance logs with optional filters + */ + async getComplianceLogs(filters: ComplianceLogFilters = {}): Promise { + const { + userId, + regulation, + complianceStatus, + riskLevel, + remediationRequired, + dateFrom, + dateTo, + limit = 100, + offset = 0, + } = filters; + + const conditions: string[] = []; + const params: (string | number | boolean | Date)[] = []; + let paramIndex = 1; + + if (userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(userId); + } + + if (regulation) { + conditions.push(`regulation = $${paramIndex++}`); + params.push(regulation); + } + + if (complianceStatus) { + conditions.push(`compliance_status = $${paramIndex++}`); + params.push(complianceStatus); + } + + if (riskLevel) { + conditions.push(`risk_level = $${paramIndex++}`); + params.push(riskLevel); + } + + if (remediationRequired !== undefined) { + conditions.push(`remediation_required = $${paramIndex++}`); + params.push(remediationRequired); + } + + if (dateFrom) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(dateFrom); + } + + if (dateTo) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(dateTo); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await db.query( + `SELECT * FROM audit.compliance_logs + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, limit, offset] + ); + + return result.rows.map(this.transformComplianceLog); + } + + /** + * Create a new compliance log entry + */ + async logComplianceEvent(data: CreateComplianceLogInput): Promise { + const { + regulation, + requirement, + eventType, + eventDescription, + userId, + systemInitiated = false, + complianceStatus, + riskLevel, + evidence, + remediationRequired = false, + remediationDeadline, + remediationNotes, + metadata = {}, + } = data; + + const result = await db.query( + `INSERT INTO audit.compliance_logs ( + regulation, requirement, event_type, event_description, + user_id, system_initiated, compliance_status, risk_level, + evidence, remediation_required, remediation_deadline, + remediation_notes, metadata + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + ) RETURNING *`, + [ + regulation, + requirement, + eventType, + eventDescription, + userId || null, + systemInitiated, + complianceStatus, + riskLevel || null, + evidence ? JSON.stringify(evidence) : null, + remediationRequired, + remediationDeadline || null, + remediationNotes || null, + JSON.stringify(metadata), + ] + ); + + logger.info('[AuditService] Compliance event logged:', { + regulation, + requirement, + complianceStatus, + riskLevel, + }); + + return this.transformComplianceLog(result.rows[0]); + } + + /** + * Get audit statistics + */ + async getAuditStats(dateFrom?: Date, dateTo?: Date): Promise { + const conditions: string[] = []; + const params: Date[] = []; + let paramIndex = 1; + + if (dateFrom) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(dateFrom); + } + + if (dateTo) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(dateTo); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const result = await db.query<{ + total: string; + event_type: string; + severity: string; + count: string; + }>( + `SELECT + COUNT(*) as total, + event_type, + severity, + COUNT(*) as count + FROM audit.audit_logs + ${whereClause} + GROUP BY event_type, severity`, + params + ); + + const stats: AuditStats = { + totalLogs: 0, + byEventType: {} as Record, + bySeverity: {} as Record, + criticalEvents: 0, + }; + + result.rows.forEach((row) => { + const count = parseInt(row.count, 10); + stats.totalLogs += count; + + if (row.event_type) { + stats.byEventType[row.event_type as AuditEventType] = + (stats.byEventType[row.event_type as AuditEventType] || 0) + count; + } + + if (row.severity) { + stats.bySeverity[row.severity as EventSeverity] = + (stats.bySeverity[row.severity as EventSeverity] || 0) + count; + + if (row.severity === 'critical' || row.severity === 'error') { + stats.criticalEvents += count; + } + } + }); + + return stats; + } + + /** + * Transform database row to AuditLog + */ + private transformAuditLog(row: AuditLogRow): AuditLog { + return { + id: row.id, + eventType: row.event_type as AuditLog['eventType'], + eventStatus: row.event_status as AuditLog['eventStatus'], + severity: row.severity as AuditLog['severity'], + userId: row.user_id, + sessionId: row.session_id, + ipAddress: row.ip_address, + userAgent: row.user_agent, + resourceType: row.resource_type as AuditLog['resourceType'], + resourceId: row.resource_id, + resourceName: row.resource_name, + action: row.action, + description: row.description, + oldValues: row.old_values ? JSON.parse(row.old_values) : null, + newValues: row.new_values ? JSON.parse(row.new_values) : null, + metadata: JSON.parse(row.metadata), + requestId: row.request_id, + correlationId: row.correlation_id, + serviceName: row.service_name, + createdAt: new Date(row.created_at), + }; + } + + /** + * Transform database row to SecurityEvent + */ + private transformSecurityEvent(row: SecurityEventRow): SecurityEvent { + return { + id: row.id, + category: row.category as SecurityEvent['category'], + severity: row.severity as SecurityEvent['severity'], + eventStatus: row.event_status as SecurityEvent['eventStatus'], + userId: row.user_id, + ipAddress: row.ip_address, + userAgent: row.user_agent, + geoLocation: row.geo_location ? JSON.parse(row.geo_location) : null, + eventCode: row.event_code, + eventName: row.event_name, + description: row.description, + requestPath: row.request_path, + requestMethod: row.request_method, + responseCode: row.response_code, + riskScore: row.risk_score ? parseFloat(row.risk_score) : null, + isBlocked: row.is_blocked, + blockReason: row.block_reason, + requiresReview: row.requires_review, + reviewedBy: row.reviewed_by, + reviewedAt: row.reviewed_at ? new Date(row.reviewed_at) : null, + reviewNotes: row.review_notes, + rawData: JSON.parse(row.raw_data), + createdAt: new Date(row.created_at), + }; + } + + /** + * Transform database row to ComplianceLog + */ + private transformComplianceLog(row: ComplianceLogRow): ComplianceLog { + return { + id: row.id, + regulation: row.regulation as ComplianceLog['regulation'], + requirement: row.requirement, + eventType: row.event_type, + eventDescription: row.event_description, + userId: row.user_id, + systemInitiated: row.system_initiated, + complianceStatus: row.compliance_status as ComplianceLog['complianceStatus'], + riskLevel: row.risk_level as ComplianceLog['riskLevel'], + evidence: row.evidence ? JSON.parse(row.evidence) : null, + remediationRequired: row.remediation_required, + remediationDeadline: row.remediation_deadline ? new Date(row.remediation_deadline) : null, + remediationNotes: row.remediation_notes, + reviewedBy: row.reviewed_by, + reviewedAt: row.reviewed_at ? new Date(row.reviewed_at) : null, + metadata: JSON.parse(row.metadata), + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } +} + +export const auditService = new AuditService(); diff --git a/src/modules/audit/types/audit.types.ts b/src/modules/audit/types/audit.types.ts new file mode 100644 index 0000000..7898541 --- /dev/null +++ b/src/modules/audit/types/audit.types.ts @@ -0,0 +1,239 @@ +/** + * Audit Module Types + * Type definitions for audit logging, security events, and compliance + */ + +export type AuditEventType = + | 'create' + | 'read' + | 'update' + | 'delete' + | 'login' + | 'logout' + | 'permission_change' + | 'config_change' + | 'export' + | 'import'; + +export type EventSeverity = + | 'debug' + | 'info' + | 'warning' + | 'error' + | 'critical'; + +export type SecurityEventCategory = + | 'authentication' + | 'authorization' + | 'data_access' + | 'configuration' + | 'suspicious_activity' + | 'compliance'; + +export type EventStatus = + | 'success' + | 'failure' + | 'blocked' + | 'pending_review'; + +export type ResourceType = + | 'user' + | 'account' + | 'transaction' + | 'order' + | 'position' + | 'bot' + | 'subscription' + | 'payment' + | 'course' + | 'model' + | 'system_config'; + +export type ComplianceStatus = + | 'compliant' + | 'non_compliant' + | 'remediation'; + +export type RiskLevel = + | 'low' + | 'medium' + | 'high' + | 'critical'; + +export type Regulation = + | 'GDPR' + | 'CCPA' + | 'SOX' + | 'PCI-DSS' + | 'MiFID'; + +export interface AuditLog { + id: string; + eventType: AuditEventType; + eventStatus: EventStatus; + severity: EventSeverity; + userId: string | null; + sessionId: string | null; + ipAddress: string | null; + userAgent: string | null; + resourceType: ResourceType; + resourceId: string | null; + resourceName: string | null; + action: string; + description: string | null; + oldValues: Record | null; + newValues: Record | null; + metadata: Record; + requestId: string | null; + correlationId: string | null; + serviceName: string | null; + createdAt: Date; +} + +export interface CreateAuditLogInput { + eventType: AuditEventType; + eventStatus?: EventStatus; + severity?: EventSeverity; + userId?: string; + sessionId?: string; + ipAddress?: string; + userAgent?: string; + resourceType: ResourceType; + resourceId?: string; + resourceName?: string; + action: string; + description?: string; + oldValues?: Record; + newValues?: Record; + metadata?: Record; + requestId?: string; + correlationId?: string; + serviceName?: string; +} + +export interface SecurityEvent { + id: string; + category: SecurityEventCategory; + severity: EventSeverity; + eventStatus: EventStatus; + userId: string | null; + ipAddress: string; + userAgent: string | null; + geoLocation: Record | null; + eventCode: string; + eventName: string; + description: string | null; + requestPath: string | null; + requestMethod: string | null; + responseCode: number | null; + riskScore: number | null; + isBlocked: boolean; + blockReason: string | null; + requiresReview: boolean; + reviewedBy: string | null; + reviewedAt: Date | null; + reviewNotes: string | null; + rawData: Record; + createdAt: Date; +} + +export interface CreateSecurityEventInput { + category: SecurityEventCategory; + severity: EventSeverity; + eventStatus?: EventStatus; + userId?: string; + ipAddress: string; + userAgent?: string; + geoLocation?: Record; + eventCode: string; + eventName: string; + description?: string; + requestPath?: string; + requestMethod?: string; + responseCode?: number; + riskScore?: number; + isBlocked?: boolean; + blockReason?: string; + requiresReview?: boolean; + rawData?: Record; +} + +export interface ComplianceLog { + id: string; + regulation: Regulation; + requirement: string; + eventType: string; + eventDescription: string; + userId: string | null; + systemInitiated: boolean; + complianceStatus: ComplianceStatus; + riskLevel: RiskLevel | null; + evidence: Record | null; + remediationRequired: boolean; + remediationDeadline: Date | null; + remediationNotes: string | null; + reviewedBy: string | null; + reviewedAt: Date | null; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateComplianceLogInput { + regulation: Regulation; + requirement: string; + eventType: string; + eventDescription: string; + userId?: string; + systemInitiated?: boolean; + complianceStatus: ComplianceStatus; + riskLevel?: RiskLevel; + evidence?: Record; + remediationRequired?: boolean; + remediationDeadline?: Date; + remediationNotes?: string; + metadata?: Record; +} + +export interface AuditLogFilters { + userId?: string; + eventType?: AuditEventType; + resourceType?: ResourceType; + resourceId?: string; + severity?: EventSeverity; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export interface SecurityEventFilters { + userId?: string; + category?: SecurityEventCategory; + severity?: EventSeverity; + isBlocked?: boolean; + requiresReview?: boolean; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export interface ComplianceLogFilters { + userId?: string; + regulation?: Regulation; + complianceStatus?: ComplianceStatus; + riskLevel?: RiskLevel; + remediationRequired?: boolean; + dateFrom?: Date; + dateTo?: Date; + limit?: number; + offset?: number; +} + +export interface AuditStats { + totalLogs: number; + byEventType: Record; + bySeverity: Record; + criticalEvents: number; +} diff --git a/src/modules/proxy/controllers/proxy.controller.ts b/src/modules/proxy/controllers/proxy.controller.ts new file mode 100644 index 0000000..c245380 --- /dev/null +++ b/src/modules/proxy/controllers/proxy.controller.ts @@ -0,0 +1,414 @@ +// Proxy Controller - REST API for Python Services Gateway +// ARCH-001 Fix: Exposes proxy endpoints with authentication + +import { Request, Response } from 'express'; +import { proxyService } from '../services/proxy.service'; + +export class ProxyController { + // ========== ML Engine Endpoints ========== + + async getLatestSignal(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getLatestSignal(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getActiveSignals(_req: Request, res: Response): Promise { + const result = await proxyService.getActiveSignals(); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getAMDPhase(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getAMDPhase(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getRangePrediction(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getRangePrediction(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async generateSignal(req: Request, res: Response): Promise { + const { symbol, timeframe } = req.body; + const result = await proxyService.generateSignal({ symbol, timeframe }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async runMLBacktest(req: Request, res: Response): Promise { + const { symbol, strategy, startDate, endDate } = req.body; + const result = await proxyService.runMLBacktest({ + symbol, + strategy, + startDate, + endDate, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getICTAnalysis(req: Request, res: Response): Promise { + const { symbol } = req.params; + const { timeframe } = req.body; + const result = await proxyService.getICTAnalysis(symbol, { timeframe }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getEnsembleSignal(req: Request, res: Response): Promise { + const { symbol } = req.params; + const { models } = req.body; + const result = await proxyService.getEnsembleSignal(symbol, { models }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getQuickSignal(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getQuickSignal(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async scanSymbols(req: Request, res: Response): Promise { + const { symbols, filters } = req.body; + const result = await proxyService.scanSymbols({ symbols, filters }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getMLModels(_req: Request, res: Response): Promise { + const result = await proxyService.getMLModels(); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getMLModelStatus(req: Request, res: Response): Promise { + const { modelId } = req.params; + const result = await proxyService.getMLModelStatus(modelId); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async updateMLModelStatus(req: Request, res: Response): Promise { + const { modelId } = req.params; + const { status } = req.body; + const result = await proxyService.updateMLModelStatus(modelId, status); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getModelAccuracy(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getModelAccuracy(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getAvailableStrategies(_req: Request, res: Response): Promise { + const result = await proxyService.getAvailableStrategies(); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async compareStrategies(req: Request, res: Response): Promise { + const { symbol, strategies, startDate, endDate } = req.body; + const result = await proxyService.compareStrategies({ + symbol, + strategies, + startDate, + endDate, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getHistoricalPredictions(req: Request, res: Response): Promise { + const { symbol } = req.params; + const { startDate, endDate } = req.query as { startDate?: string; endDate?: string }; + const result = await proxyService.getHistoricalPredictions(symbol, { + startDate, + endDate, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + // ========== LLM Agent Endpoints ========== + + async analyzeSymbol(req: Request, res: Response): Promise { + const { symbol, timeframe, context } = req.body; + const result = await proxyService.analyzeSymbol({ symbol, timeframe, context }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getLLMActiveSignals(_req: Request, res: Response): Promise { + const result = await proxyService.getLLMActiveSignals(); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getRiskSummary(_req: Request, res: Response): Promise { + const result = await proxyService.getRiskSummary(); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async setRiskLevel(req: Request, res: Response): Promise { + const { level, parameters } = req.body; + const result = await proxyService.setRiskLevel({ level, parameters }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async validateTrade(req: Request, res: Response): Promise { + const { symbol, direction, size, entry, stopLoss, takeProfit } = req.body; + const result = await proxyService.validateTrade({ + symbol, + direction, + size, + entry, + stopLoss, + takeProfit, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async startLLMBacktest(req: Request, res: Response): Promise { + const { symbol, strategy, startDate, endDate, parameters } = req.body; + const result = await proxyService.startLLMBacktest({ + symbol, + strategy, + startDate, + endDate, + parameters, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async runQuickBacktest(req: Request, res: Response): Promise { + const { symbol } = req.query as { symbol?: string }; + const result = await proxyService.runQuickBacktest({ symbol }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getBacktestStatus(req: Request, res: Response): Promise { + const { id } = req.params; + const result = await proxyService.getBacktestStatus(id); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getBacktestResults(req: Request, res: Response): Promise { + const { id } = req.params; + const result = await proxyService.getBacktestResults(id); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async listBacktests(req: Request, res: Response): Promise { + const { limit, offset } = req.query as { limit?: string; offset?: string }; + const result = await proxyService.listBacktests({ + limit: limit ? parseInt(limit, 10) : undefined, + offset: offset ? parseInt(offset, 10) : undefined, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async deleteBacktest(req: Request, res: Response): Promise { + const { id } = req.params; + const result = await proxyService.deleteBacktest(id); + + if (result.success) { + res.json({ success: true }); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + // ========== Data Service Endpoints ========== + + async getHistoricalCandles(req: Request, res: Response): Promise { + const { symbol } = req.params; + const { timeframe, start, end, limit } = req.query as { + timeframe?: string; + start?: string; + end?: string; + limit?: string; + }; + const result = await proxyService.getHistoricalCandles(symbol, { + timeframe, + start, + end, + limit: limit ? parseInt(limit, 10) : undefined, + }); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + async getAvailableDateRange(req: Request, res: Response): Promise { + const { symbol } = req.params; + const result = await proxyService.getAvailableDateRange(symbol); + + if (result.success) { + res.json(result.data); + } else { + res.status(result.statusCode).json({ error: result.error }); + } + } + + // ========== Health Check Endpoints ========== + + async getMLHealth(_req: Request, res: Response): Promise { + const health = await proxyService.checkMLHealth(); + const statusCode = health.status === 'healthy' ? 200 : 503; + res.status(statusCode).json(health); + } + + async getLLMHealth(_req: Request, res: Response): Promise { + const health = await proxyService.checkLLMHealth(); + const statusCode = health.status === 'healthy' ? 200 : 503; + res.status(statusCode).json(health); + } + + async getDataServiceHealth(_req: Request, res: Response): Promise { + const health = await proxyService.checkDataServiceHealth(); + const statusCode = health.status === 'healthy' ? 200 : 503; + res.status(statusCode).json(health); + } + + async getAllServicesHealth(_req: Request, res: Response): Promise { + const healthChecks = await proxyService.checkAllServicesHealth(); + const allHealthy = healthChecks.every((h) => h.status === 'healthy'); + const statusCode = allHealthy ? 200 : 503; + res.status(statusCode).json({ + status: allHealthy ? 'healthy' : 'degraded', + services: healthChecks, + }); + } +} + +export const proxyController = new ProxyController(); diff --git a/src/modules/proxy/index.ts b/src/modules/proxy/index.ts new file mode 100644 index 0000000..0bb5e7a --- /dev/null +++ b/src/modules/proxy/index.ts @@ -0,0 +1,7 @@ +// Proxy Module - Gateway to Python Services +// ARCH-001 Fix: Centralized access to ML/LLM/Data services + +export { proxyService } from './services/proxy.service'; +export { proxyController } from './controllers/proxy.controller'; +export { default as proxyRoutes } from './proxy.routes'; +export * from './types/proxy.types'; diff --git a/src/modules/proxy/proxy.routes.ts b/src/modules/proxy/proxy.routes.ts new file mode 100644 index 0000000..ab00ad8 --- /dev/null +++ b/src/modules/proxy/proxy.routes.ts @@ -0,0 +1,89 @@ +// Proxy Routes - Gateway to Python Services +// ARCH-001 Fix: All Python service access goes through Express with auth + +import { Router } from 'express'; +import { proxyController } from './controllers/proxy.controller'; +import { authenticate } from '../../core/middleware/auth.middleware'; + +const router = Router(); + +// All proxy routes require authentication +router.use(authenticate); + +// ========== ML Engine Routes ========== +// Base path: /api/v1/proxy/ml + +// Signals +router.get('/ml/signals/latest/:symbol', (req, res) => proxyController.getLatestSignal(req, res)); +router.get('/ml/signals/active', (req, res) => proxyController.getActiveSignals(req, res)); +router.post('/ml/signals/generate', (req, res) => proxyController.generateSignal(req, res)); + +// AMD Phase Detection +router.get('/ml/amd/:symbol', (req, res) => proxyController.getAMDPhase(req, res)); + +// Predictions +router.get('/ml/predict/range/:symbol', (req, res) => proxyController.getRangePrediction(req, res)); +router.get('/ml/predictions/history/:symbol', (req, res) => proxyController.getHistoricalPredictions(req, res)); + +// ICT Analysis +router.post('/ml/ict/:symbol', (req, res) => proxyController.getICTAnalysis(req, res)); + +// Ensemble +router.post('/ml/ensemble/:symbol', (req, res) => proxyController.getEnsembleSignal(req, res)); +router.get('/ml/ensemble/quick/:symbol', (req, res) => proxyController.getQuickSignal(req, res)); + +// Scanner +router.post('/ml/scan', (req, res) => proxyController.scanSymbols(req, res)); + +// Backtest +router.post('/ml/backtest/run', (req, res) => proxyController.runMLBacktest(req, res)); +router.post('/ml/backtest/compare', (req, res) => proxyController.compareStrategies(req, res)); + +// Models (Admin) +router.get('/ml/models', (req, res) => proxyController.getMLModels(req, res)); +router.get('/ml/models/:modelId/status', (req, res) => proxyController.getMLModelStatus(req, res)); +router.patch('/ml/models/:modelId/status', (req, res) => proxyController.updateMLModelStatus(req, res)); +router.get('/ml/models/accuracy/:symbol', (req, res) => proxyController.getModelAccuracy(req, res)); + +// Strategies +router.get('/ml/strategies', (req, res) => proxyController.getAvailableStrategies(req, res)); + +// Health +router.get('/ml/health', (req, res) => proxyController.getMLHealth(req, res)); + +// ========== LLM Agent Routes ========== +// Base path: /api/v1/proxy/llm + +// Analysis +router.post('/llm/analyze', (req, res) => proxyController.analyzeSymbol(req, res)); +router.get('/llm/signals/active', (req, res) => proxyController.getLLMActiveSignals(req, res)); + +// Risk +router.get('/llm/risk/summary', (req, res) => proxyController.getRiskSummary(req, res)); +router.post('/llm/risk/level', (req, res) => proxyController.setRiskLevel(req, res)); + +// Trade Validation +router.post('/llm/validate-trade', (req, res) => proxyController.validateTrade(req, res)); + +// Backtesting +router.post('/llm/backtest/run', (req, res) => proxyController.startLLMBacktest(req, res)); +router.get('/llm/backtest/quick', (req, res) => proxyController.runQuickBacktest(req, res)); +router.get('/llm/backtest/status/:id', (req, res) => proxyController.getBacktestStatus(req, res)); +router.get('/llm/backtest/results/:id', (req, res) => proxyController.getBacktestResults(req, res)); +router.get('/llm/backtest/list', (req, res) => proxyController.listBacktests(req, res)); +router.delete('/llm/backtest/:id', (req, res) => proxyController.deleteBacktest(req, res)); + +// Health +router.get('/llm/health', (req, res) => proxyController.getLLMHealth(req, res)); + +// ========== Data Service Routes ========== +// Base path: /api/v1/proxy/data + +router.get('/data/candles/:symbol', (req, res) => proxyController.getHistoricalCandles(req, res)); +router.get('/data/symbols/:symbol/date-range', (req, res) => proxyController.getAvailableDateRange(req, res)); +router.get('/data/health', (req, res) => proxyController.getDataServiceHealth(req, res)); + +// ========== Combined Health Check ========== +router.get('/health', (req, res) => proxyController.getAllServicesHealth(req, res)); + +export default router; diff --git a/src/modules/proxy/services/proxy.service.ts b/src/modules/proxy/services/proxy.service.ts new file mode 100644 index 0000000..2925247 --- /dev/null +++ b/src/modules/proxy/services/proxy.service.ts @@ -0,0 +1,444 @@ +// Proxy Service - Gateway to Python Services +// ARCH-001 Fix: Centralized proxy with auth, logging, and error handling + +import { config } from '../../../config'; +import { + ProxyConfig, + ProxyRequestOptions, + ProxyResponse, + ServiceHealth, +} from '../types/proxy.types'; + +const proxyConfig: ProxyConfig = { + mlEngineUrl: config.mlEngine?.baseUrl || 'http://localhost:3083', + llmAgentUrl: config.llmAgent?.url || 'http://localhost:3085', + dataServiceUrl: config.dataService?.url || 'http://localhost:3084', + timeout: Math.max( + config.mlEngine?.timeout || 5000, + config.llmAgent?.timeout || 30000, + config.dataService?.timeout || 10000 + ), +}; + +class ProxyService { + private async makeRequest( + baseUrl: string, + options: ProxyRequestOptions + ): Promise> { + const startTime = Date.now(); + const url = `${baseUrl}${options.path}`; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + options.timeout || proxyConfig.timeout + ); + + const fetchOptions: RequestInit = { + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + signal: controller.signal, + }; + + if (options.body && ['POST', 'PUT', 'PATCH'].includes(options.method)) { + fetchOptions.body = JSON.stringify(options.body); + } + + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + const latency = Date.now() - startTime; + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: errorText || `HTTP ${response.status}`, + statusCode: response.status, + latency, + }; + } + + const data = await response.json(); + return { + success: true, + data: data as T, + statusCode: response.status, + latency, + }; + } catch (error) { + const latency = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + if (errorMessage.includes('abort')) { + return { + success: false, + error: 'Request timeout', + statusCode: 408, + latency, + }; + } + + return { + success: false, + error: errorMessage, + statusCode: 500, + latency, + }; + } + } + + // ========== ML Engine Proxy ========== + + async mlRequest(options: ProxyRequestOptions): Promise> { + return this.makeRequest(proxyConfig.mlEngineUrl, options); + } + + async getLatestSignal(symbol: string) { + return this.mlRequest({ + method: 'GET', + path: `/api/v1/signals/latest/${symbol}`, + }); + } + + async getActiveSignals() { + return this.mlRequest({ + method: 'GET', + path: '/api/v1/signals/active', + }); + } + + async getAMDPhase(symbol: string) { + return this.mlRequest({ + method: 'GET', + path: `/api/v1/amd/detect/${symbol}`, + }); + } + + async getRangePrediction(symbol: string) { + return this.mlRequest({ + method: 'GET', + path: `/api/v1/predict/range/${symbol}`, + }); + } + + async generateSignal(params: { symbol: string; timeframe: string }) { + return this.mlRequest({ + method: 'POST', + path: '/api/v1/signals/generate', + body: params, + }); + } + + async runMLBacktest(params: { + symbol: string; + strategy: string; + startDate: string; + endDate: string; + }) { + return this.mlRequest({ + method: 'POST', + path: '/api/v1/backtest/run', + body: params, + }); + } + + async getICTAnalysis(symbol: string, params?: { timeframe?: string }) { + return this.mlRequest({ + method: 'POST', + path: `/api/ict/${symbol}`, + body: params || {}, + }); + } + + async getEnsembleSignal(symbol: string, params?: { models?: string[] }) { + return this.mlRequest({ + method: 'POST', + path: `/api/ensemble/${symbol}`, + body: params || {}, + }); + } + + async getQuickSignal(symbol: string) { + return this.mlRequest({ + method: 'GET', + path: `/api/ensemble/quick/${symbol}`, + }); + } + + async scanSymbols(params: { symbols: string[]; filters?: Record }) { + return this.mlRequest({ + method: 'POST', + path: '/api/scan', + body: params, + }); + } + + async getMLModels() { + return this.mlRequest({ + method: 'GET', + path: '/models', + }); + } + + async getMLModelStatus(modelId: string) { + return this.mlRequest({ + method: 'GET', + path: `/models/${modelId}/status`, + }); + } + + async updateMLModelStatus(modelId: string, status: string) { + return this.mlRequest({ + method: 'PATCH', + path: `/models/${modelId}/status`, + body: { status }, + }); + } + + async getModelAccuracy(symbol: string) { + return this.mlRequest({ + method: 'GET', + path: `/api/models/accuracy/${symbol}`, + }); + } + + async getAvailableStrategies() { + return this.mlRequest({ + method: 'GET', + path: '/api/strategies', + }); + } + + async compareStrategies(params: { + symbol: string; + strategies: string[]; + startDate: string; + endDate: string; + }) { + return this.mlRequest({ + method: 'POST', + path: '/api/backtest/compare', + body: params, + }); + } + + async getHistoricalPredictions(symbol: string, params?: { startDate?: string; endDate?: string }) { + const query = params + ? `?${new URLSearchParams(params as Record).toString()}` + : ''; + return this.mlRequest({ + method: 'GET', + path: `/api/predictions/history/${symbol}${query}`, + }); + } + + // ========== LLM Agent Proxy ========== + + async llmRequest(options: ProxyRequestOptions): Promise> { + return this.makeRequest(proxyConfig.llmAgentUrl, options); + } + + async analyzeSymbol(params: { + symbol: string; + timeframe?: string; + context?: string; + }) { + return this.llmRequest({ + method: 'POST', + path: '/api/v1/predictions/analyze', + body: params, + }); + } + + async getLLMActiveSignals() { + return this.llmRequest({ + method: 'GET', + path: '/api/v1/predictions/active-signals', + }); + } + + async getRiskSummary() { + return this.llmRequest({ + method: 'GET', + path: '/api/v1/predictions/risk-summary', + }); + } + + async setRiskLevel(params: { level: string; parameters?: Record }) { + return this.llmRequest({ + method: 'POST', + path: '/api/v1/predictions/risk-level', + body: params, + }); + } + + async validateTrade(params: { + symbol: string; + direction: 'long' | 'short'; + size: number; + entry: number; + stopLoss: number; + takeProfit: number; + }) { + return this.llmRequest({ + method: 'POST', + path: '/api/v1/predictions/validate-trade', + body: params, + }); + } + + async startLLMBacktest(params: { + symbol: string; + strategy: string; + startDate: string; + endDate: string; + parameters?: Record; + }) { + return this.llmRequest({ + method: 'POST', + path: '/api/v1/backtesting/run', + body: params, + }); + } + + async runQuickBacktest(params?: { symbol?: string }) { + const query = params?.symbol ? `?symbol=${params.symbol}` : ''; + return this.llmRequest({ + method: 'GET', + path: `/api/v1/backtesting/quick-test${query}`, + }); + } + + async getBacktestStatus(id: string) { + return this.llmRequest({ + method: 'GET', + path: `/api/v1/backtesting/status/${id}`, + }); + } + + async getBacktestResults(id: string) { + return this.llmRequest({ + method: 'GET', + path: `/api/v1/backtesting/results/${id}`, + }); + } + + async listBacktests(params?: { limit?: number; offset?: number }) { + const query = params + ? `?${new URLSearchParams(params as Record).toString()}` + : ''; + return this.llmRequest({ + method: 'GET', + path: `/api/v1/backtesting/list${query}`, + }); + } + + async deleteBacktest(id: string) { + return this.llmRequest({ + method: 'DELETE', + path: `/api/v1/backtesting/${id}`, + }); + } + + // ========== Data Service Proxy ========== + + async dataRequest(options: ProxyRequestOptions): Promise> { + return this.makeRequest(proxyConfig.dataServiceUrl, options); + } + + async getHistoricalCandles( + symbol: string, + params?: { timeframe?: string; start?: string; end?: string; limit?: number } + ) { + const query = params + ? `?${new URLSearchParams(params as Record).toString()}` + : ''; + return this.dataRequest({ + method: 'GET', + path: `/api/v1/candles/${symbol}${query}`, + }); + } + + async getAvailableDateRange(symbol: string) { + return this.dataRequest({ + method: 'GET', + path: `/api/v1/symbols/${symbol}/date-range`, + }); + } + + // ========== Health Checks ========== + + async checkMLHealth(): Promise { + const startTime = Date.now(); + try { + const response = await this.mlRequest({ method: 'GET', path: '/health' }); + return { + service: 'ml-engine', + status: response.success ? 'healthy' : 'unhealthy', + latency: response.latency, + details: response.data as Record, + }; + } catch { + return { + service: 'ml-engine', + status: 'unhealthy', + latency: Date.now() - startTime, + }; + } + } + + async checkLLMHealth(): Promise { + const startTime = Date.now(); + try { + const response = await this.llmRequest({ method: 'GET', path: '/' }); + return { + service: 'llm-agent', + status: response.success ? 'healthy' : 'unhealthy', + latency: response.latency, + details: response.data as Record, + }; + } catch { + return { + service: 'llm-agent', + status: 'unhealthy', + latency: Date.now() - startTime, + }; + } + } + + async checkDataServiceHealth(): Promise { + const startTime = Date.now(); + try { + const response = await this.dataRequest({ method: 'GET', path: '/health' }); + return { + service: 'data-service', + status: response.success ? 'healthy' : 'unhealthy', + latency: response.latency, + details: response.data as Record, + }; + } catch { + return { + service: 'data-service', + status: 'unhealthy', + latency: Date.now() - startTime, + }; + } + } + + async checkAllServicesHealth(): Promise { + const [ml, llm, data] = await Promise.all([ + this.checkMLHealth(), + this.checkLLMHealth(), + this.checkDataServiceHealth(), + ]); + return [ml, llm, data]; + } +} + +export const proxyService = new ProxyService(); diff --git a/src/modules/proxy/types/proxy.types.ts b/src/modules/proxy/types/proxy.types.ts new file mode 100644 index 0000000..7aef908 --- /dev/null +++ b/src/modules/proxy/types/proxy.types.ts @@ -0,0 +1,178 @@ +// Proxy Types - Gateway to Python Services +// ARCH-001 Fix: Centralize access to ML/LLM services via Express + +export interface ProxyConfig { + mlEngineUrl: string; + llmAgentUrl: string; + dataServiceUrl: string; + timeout: number; +} + +export interface ProxyRequestOptions { + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + body?: unknown; + headers?: Record; + timeout?: number; +} + +export interface ProxyResponse { + success: boolean; + data?: T; + error?: string; + statusCode: number; + latency: number; +} + +// ML Engine Types +export interface MLSignal { + id: string; + symbol: string; + direction: 'long' | 'short' | 'neutral'; + confidence: number; + entry_price: number; + stop_loss: number; + take_profit: number; + timestamp: string; + model: string; + features?: Record; +} + +export interface AMDPhase { + symbol: string; + phase: 'accumulation' | 'manipulation' | 'distribution'; + confidence: number; + details: { + volume_profile: string; + price_action: string; + order_flow: string; + }; +} + +export interface RangePrediction { + symbol: string; + timeframe: string; + high_predicted: number; + low_predicted: number; + current_price: number; + confidence: number; +} + +export interface ICTAnalysis { + symbol: string; + order_blocks: Array<{ + type: 'bullish' | 'bearish'; + price_high: number; + price_low: number; + timestamp: string; + strength: number; + }>; + fair_value_gaps: Array<{ + type: 'bullish' | 'bearish'; + top: number; + bottom: number; + filled_percent: number; + }>; + liquidity_pools: Array<{ + type: 'buy_side' | 'sell_side'; + price: number; + strength: number; + }>; +} + +export interface EnsembleSignal { + symbol: string; + consensus_direction: 'long' | 'short' | 'neutral'; + consensus_confidence: number; + models: Array<{ + name: string; + direction: 'long' | 'short' | 'neutral'; + confidence: number; + weight: number; + }>; + entry_zone: { min: number; max: number }; + stop_loss: number; + targets: number[]; +} + +// LLM Agent Types +export interface LLMAnalysisRequest { + symbol: string; + timeframe?: string; + context?: string; + include_technicals?: boolean; + include_fundamentals?: boolean; +} + +export interface LLMAnalysisResponse { + symbol: string; + analysis: string; + sentiment: 'bullish' | 'bearish' | 'neutral'; + key_levels: { + support: number[]; + resistance: number[]; + }; + recommendation: string; + confidence: number; +} + +export interface BacktestRequest { + symbol: string; + strategy: string; + start_date: string; + end_date: string; + initial_capital?: number; + parameters?: Record; +} + +export interface BacktestResult { + id: string; + symbol: string; + strategy: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + metrics?: { + total_return: number; + sharpe_ratio: number; + max_drawdown: number; + win_rate: number; + profit_factor: number; + total_trades: number; + }; + equity_curve?: Array<{ date: string; value: number }>; + trades?: Array<{ + entry_time: string; + exit_time: string; + direction: 'long' | 'short'; + entry_price: number; + exit_price: number; + pnl: number; + pnl_percent: number; + }>; +} + +// Data Service Types +export interface CandleData { + timestamp: string; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export interface HistoricalDataRequest { + symbol: string; + timeframe: string; + start?: string; + end?: string; + limit?: number; +} + +// Health Check Types +export interface ServiceHealth { + service: string; + status: 'healthy' | 'degraded' | 'unhealthy'; + latency: number; + version?: string; + details?: Record; +} diff --git a/src/modules/risk/controllers/risk.controller.ts b/src/modules/risk/controllers/risk.controller.ts new file mode 100644 index 0000000..7a4a9c7 --- /dev/null +++ b/src/modules/risk/controllers/risk.controller.ts @@ -0,0 +1,266 @@ +/** + * Risk Assessment Controller + * Handles HTTP endpoints for risk questionnaire and assessments + */ + +import { Request, Response, NextFunction } from 'express'; +import { riskService } from '../services/risk.service'; + +// ============================================================================ +// Types +// ============================================================================ + +type AuthRequest = Request; + +// ============================================================================ +// Controllers +// ============================================================================ + +/** + * GET /api/v1/risk/questions + * Get all risk questionnaire questions + */ +export async function getQuestions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const questions = riskService.getQuestions(); + + res.json({ + success: true, + data: questions, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/risk/assessment + * Get current user's risk assessment + */ +export async function getCurrentUserAssessment( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const assessment = await riskService.getUserAssessment(req.user.id); + + if (!assessment) { + res.status(404).json({ + success: false, + error: { + message: 'No risk assessment found', + code: 'NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: assessment, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/risk/assessment/valid + * Check if current user has a valid (non-expired) assessment + */ +export async function checkValidAssessment( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const isValid = await riskService.isAssessmentValid(req.user.id); + const assessment = isValid + ? await riskService.getValidAssessment(req.user.id) + : null; + + res.json({ + success: true, + data: { + isValid, + assessment, + }, + }); + } catch (error) { + next(error); + } +} + +/** + * POST /api/v1/risk/assessment + * Submit risk questionnaire responses + * Body: { responses: [{ questionId, answer }] } + */ +export async function submitAssessment( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { responses, completionTimeSeconds } = req.body; + + if (!responses || !Array.isArray(responses)) { + res.status(400).json({ + success: false, + error: { + message: 'Invalid request: responses array is required', + code: 'INVALID_REQUEST', + }, + }); + return; + } + + const assessment = await riskService.submitAssessment({ + userId: req.user.id, + responses, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + completionTimeSeconds, + }); + + res.status(201).json({ + success: true, + data: assessment, + message: `Risk assessment completed. Your profile: ${assessment.riskProfile}`, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/risk/assessment/:userId + * Get risk assessment for specific user (admin only) + */ +export async function getUserAssessment( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { userId } = req.params; + + if (!userId) { + res.status(400).json({ + success: false, + error: { + message: 'User ID is required', + code: 'INVALID_REQUEST', + }, + }); + return; + } + + const assessment = await riskService.getUserAssessment(userId); + + if (!assessment) { + res.status(404).json({ + success: false, + error: { + message: 'No risk assessment found for this user', + code: 'NOT_FOUND', + }, + }); + return; + } + + res.json({ + success: true, + data: assessment, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/risk/assessment/history + * Get assessment history for current user + */ +export async function getAssessmentHistory( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise { + try { + if (!req.user) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const history = await riskService.getAssessmentHistory(req.user.id); + + res.json({ + success: true, + data: history, + }); + } catch (error) { + next(error); + } +} + +/** + * GET /api/v1/risk/statistics + * Get risk profile statistics (admin only) + */ +export async function getStatistics( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const statistics = await riskService.getProfileStatistics(); + + res.json({ + success: true, + data: statistics, + }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/risk/index.ts b/src/modules/risk/index.ts new file mode 100644 index 0000000..680319f --- /dev/null +++ b/src/modules/risk/index.ts @@ -0,0 +1,10 @@ +/** + * Risk Assessment Module + * Exports for risk questionnaire and profile assessment + */ + +export * from './types/risk.types'; +export * from './services/risk.service'; +export * from './controllers/risk.controller'; +export * from './repositories/risk.repository'; +export { riskRouter } from './risk.routes'; diff --git a/src/modules/risk/repositories/risk.repository.ts b/src/modules/risk/repositories/risk.repository.ts new file mode 100644 index 0000000..f8236a5 --- /dev/null +++ b/src/modules/risk/repositories/risk.repository.ts @@ -0,0 +1,272 @@ +/** + * Risk Questionnaire Repository + * Handles database operations for risk assessments + */ + +import { db } from '../../../shared/database'; +import type { + RiskQuestionnaireRow, + RiskAssessment, + RiskProfile, + TradingAgent, + RiskQuestionnaireResponse, +} from '../types/risk.types'; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToAssessment(row: RiskQuestionnaireRow): RiskAssessment { + const now = new Date(); + const isExpired = new Date(row.expires_at) < now; + + return { + id: row.id, + userId: row.user_id, + responses: row.responses.map((r) => ({ + questionId: r.question_id, + answer: r.answer, + score: r.score, + })), + totalScore: row.total_score, + riskProfile: row.calculated_profile, + recommendedAgent: row.recommended_agent, + completedAt: row.completed_at, + expiresAt: row.expires_at, + isExpired, + ipAddress: row.ip_address, + userAgent: row.user_agent, + completionTimeSeconds: row.completion_time_seconds, + createdAt: row.created_at, + }; +} + +// ============================================================================ +// Repository +// ============================================================================ + +class RiskRepository { + /** + * Find user's most recent risk assessment + */ + async findByUserId(userId: string): Promise { + const query = ` + SELECT + id, + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + completed_at, + expires_at, + ip_address, + user_agent, + completion_time_seconds, + created_at + FROM investment.risk_questionnaire + WHERE user_id = $1 + ORDER BY completed_at DESC + LIMIT 1 + `; + + const result = await db.query(query, [userId]); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAssessment(result.rows[0]); + } + + /** + * Find assessment by ID + */ + async findById(id: string): Promise { + const query = ` + SELECT + id, + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + completed_at, + expires_at, + ip_address, + user_agent, + completion_time_seconds, + created_at + FROM investment.risk_questionnaire + WHERE id = $1 + `; + + const result = await db.query(query, [id]); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAssessment(result.rows[0]); + } + + /** + * Find user's valid (non-expired) assessment + */ + async findValidAssessment(userId: string): Promise { + const query = ` + SELECT + id, + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + completed_at, + expires_at, + ip_address, + user_agent, + completion_time_seconds, + created_at + FROM investment.risk_questionnaire + WHERE user_id = $1 + AND expires_at > NOW() + ORDER BY completed_at DESC + LIMIT 1 + `; + + const result = await db.query(query, [userId]); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAssessment(result.rows[0]); + } + + /** + * Create a new risk assessment + */ + async create(input: { + userId: string; + responses: RiskQuestionnaireResponse[]; + totalScore: number; + calculatedProfile: RiskProfile; + recommendedAgent: TradingAgent | null; + ipAddress?: string; + userAgent?: string; + completionTimeSeconds?: number; + }): Promise { + const expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + const responsesJsonb = input.responses.map((r) => ({ + question_id: r.questionId, + answer: r.answer, + score: r.score, + })); + + const query = ` + INSERT INTO investment.risk_questionnaire ( + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + expires_at, + ip_address, + user_agent, + completion_time_seconds + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING + id, + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + completed_at, + expires_at, + ip_address, + user_agent, + completion_time_seconds, + created_at + `; + + const result = await db.query(query, [ + input.userId, + JSON.stringify(responsesJsonb), + input.totalScore, + input.calculatedProfile, + input.recommendedAgent, + expiresAt, + input.ipAddress || null, + input.userAgent || null, + input.completionTimeSeconds || null, + ]); + + return mapRowToAssessment(result.rows[0]); + } + + /** + * Get all assessments for a user (history) + */ + async findAllByUserId(userId: string): Promise { + const query = ` + SELECT + id, + user_id, + responses, + total_score, + calculated_profile, + recommended_agent, + completed_at, + expires_at, + ip_address, + user_agent, + completion_time_seconds, + created_at + FROM investment.risk_questionnaire + WHERE user_id = $1 + ORDER BY completed_at DESC + `; + + const result = await db.query(query, [userId]); + + return result.rows.map(mapRowToAssessment); + } + + /** + * Count assessments by risk profile + */ + async countByProfile(): Promise> { + const query = ` + SELECT + calculated_profile, + COUNT(*) as count + FROM investment.risk_questionnaire + WHERE expires_at > NOW() + GROUP BY calculated_profile + `; + + const result = await db.query<{ + calculated_profile: RiskProfile; + count: string; + }>(query); + + const counts: Record = { + conservative: 0, + moderate: 0, + aggressive: 0, + }; + + result.rows.forEach((row) => { + counts[row.calculated_profile] = parseInt(row.count, 10); + }); + + return counts; + } +} + +export const riskRepository = new RiskRepository(); diff --git a/src/modules/risk/risk.routes.ts b/src/modules/risk/risk.routes.ts new file mode 100644 index 0000000..bf5dc36 --- /dev/null +++ b/src/modules/risk/risk.routes.ts @@ -0,0 +1,72 @@ +/** + * Risk Assessment Routes + * API endpoints for risk questionnaire and assessments + */ + +import { Router, RequestHandler } from 'express'; +import * as riskController from './controllers/risk.controller'; +import { requireAuth } from '../../core/guards/auth.guard'; + +const router = Router(); + +// Type cast helper for authenticated routes +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; + +// ============================================================================ +// Public Routes +// ============================================================================ + +/** + * GET /api/v1/risk/questions + * Get all risk questionnaire questions + */ +router.get('/questions', riskController.getQuestions); + +/** + * GET /api/v1/risk/statistics + * Get risk profile statistics (public aggregate data) + */ +router.get('/statistics', riskController.getStatistics); + +// ============================================================================ +// Authenticated Routes +// All routes below require authentication via JWT token +// ============================================================================ + +/** + * GET /api/v1/risk/assessment + * Get current user's most recent risk assessment + */ +router.get('/assessment', requireAuth, authHandler(riskController.getCurrentUserAssessment)); + +/** + * GET /api/v1/risk/assessment/valid + * Check if current user has a valid (non-expired) assessment + */ +router.get('/assessment/valid', requireAuth, authHandler(riskController.checkValidAssessment)); + +/** + * GET /api/v1/risk/assessment/history + * Get assessment history for current user + */ +router.get('/assessment/history', requireAuth, authHandler(riskController.getAssessmentHistory)); + +/** + * POST /api/v1/risk/assessment + * Submit risk questionnaire responses + * Body: { + * responses: [{ questionId: string, answer: string }], + * completionTimeSeconds?: number + * } + */ +router.post('/assessment', requireAuth, authHandler(riskController.submitAssessment)); + +/** + * GET /api/v1/risk/assessment/:userId + * Get risk assessment for specific user (admin only) + * Note: Should be protected with admin guard in production + */ +router.get('/assessment/:userId', requireAuth, authHandler(riskController.getUserAssessment)); + +export { router as riskRouter }; diff --git a/src/modules/risk/services/risk.service.ts b/src/modules/risk/services/risk.service.ts new file mode 100644 index 0000000..917be39 --- /dev/null +++ b/src/modules/risk/services/risk.service.ts @@ -0,0 +1,353 @@ +/** + * Risk Assessment Service + * Business logic for risk questionnaire and profile calculation + */ + +import { riskRepository } from '../repositories/risk.repository'; +import type { + RiskQuestion, + RiskAssessment, + SubmitAssessmentInput, + RiskProfile, + TradingAgent, + RiskQuestionnaireResponse, +} from '../types/risk.types'; + +// ============================================================================ +// Risk Assessment Questionnaire (15 questions) +// ============================================================================ + +const RISK_QUESTIONS: RiskQuestion[] = [ + // Experience (Q1-Q3) + { + id: 'Q1', + text: 'How long have you been investing in financial markets?', + category: 'experience', + options: [ + { value: 'none', label: 'I have no investment experience', weight: 0 }, + { value: 'less_1', label: 'Less than 1 year', weight: 2 }, + { value: '1_3', label: '1-3 years', weight: 4 }, + { value: '3_5', label: '3-5 years', weight: 6 }, + { value: 'more_5', label: 'More than 5 years', weight: 8 }, + ], + }, + { + id: 'Q2', + text: 'How familiar are you with investment concepts like diversification, asset allocation, and risk-return tradeoffs?', + category: 'experience', + options: [ + { value: 'not_familiar', label: 'Not familiar at all', weight: 0 }, + { value: 'somewhat', label: 'Somewhat familiar', weight: 3 }, + { value: 'familiar', label: 'Familiar', weight: 5 }, + { value: 'very_familiar', label: 'Very familiar', weight: 7 }, + { value: 'expert', label: 'Expert level', weight: 10 }, + ], + }, + { + id: 'Q3', + text: 'Have you ever traded stocks, forex, or cryptocurrencies?', + category: 'experience', + options: [ + { value: 'never', label: 'Never', weight: 0 }, + { value: 'tried', label: 'Tried a few times', weight: 3 }, + { value: 'occasionally', label: 'Trade occasionally', weight: 5 }, + { value: 'regularly', label: 'Trade regularly', weight: 7 }, + { value: 'professional', label: 'Professional trader', weight: 10 }, + ], + }, + + // Investment Goals (Q4-Q6) + { + id: 'Q4', + text: 'What is your primary investment goal?', + category: 'goals', + options: [ + { value: 'preserve', label: 'Preserve capital with minimal growth', weight: 0 }, + { value: 'steady_income', label: 'Generate steady income', weight: 2 }, + { value: 'balanced', label: 'Balanced growth and income', weight: 5 }, + { value: 'growth', label: 'Long-term capital growth', weight: 7 }, + { value: 'aggressive_growth', label: 'Aggressive growth', weight: 10 }, + ], + }, + { + id: 'Q5', + text: 'What percentage of your total savings are you planning to invest?', + category: 'goals', + options: [ + { value: 'less_10', label: 'Less than 10%', weight: 0 }, + { value: '10_25', label: '10-25%', weight: 3 }, + { value: '25_50', label: '25-50%', weight: 5 }, + { value: '50_75', label: '50-75%', weight: 8 }, + { value: 'more_75', label: 'More than 75%', weight: 10 }, + ], + }, + { + id: 'Q6', + text: 'What annual return do you expect from your investments?', + category: 'goals', + options: [ + { value: 'less_5', label: 'Less than 5%', weight: 0 }, + { value: '5_10', label: '5-10%', weight: 3 }, + { value: '10_20', label: '10-20%', weight: 6 }, + { value: '20_30', label: '20-30%', weight: 8 }, + { value: 'more_30', label: 'More than 30%', weight: 10 }, + ], + }, + + // Time Horizon (Q7-Q9) + { + id: 'Q7', + text: 'How long do you plan to keep your money invested?', + category: 'timeframe', + options: [ + { value: 'less_1', label: 'Less than 1 year', weight: 0 }, + { value: '1_3', label: '1-3 years', weight: 3 }, + { value: '3_5', label: '3-5 years', weight: 5 }, + { value: '5_10', label: '5-10 years', weight: 7 }, + { value: 'more_10', label: 'More than 10 years', weight: 10 }, + ], + }, + { + id: 'Q8', + text: 'When do you expect to need this money?', + category: 'timeframe', + options: [ + { value: 'immediately', label: 'Within 6 months', weight: 0 }, + { value: 'short_term', label: '6 months to 2 years', weight: 2 }, + { value: 'medium_term', label: '2-5 years', weight: 5 }, + { value: 'long_term', label: '5-10 years', weight: 7 }, + { value: 'retirement', label: 'For retirement (10+ years)', weight: 10 }, + ], + }, + { + id: 'Q9', + text: 'How would you describe your current financial situation?', + category: 'timeframe', + options: [ + { value: 'tight', label: 'I need this money for living expenses', weight: 0 }, + { value: 'stable', label: 'Stable with emergency fund', weight: 3 }, + { value: 'comfortable', label: 'Comfortable with some savings', weight: 5 }, + { value: 'strong', label: 'Strong financial position', weight: 8 }, + { value: 'wealthy', label: 'Wealthy with significant reserves', weight: 10 }, + ], + }, + + // Risk Tolerance (Q10-Q12) + { + id: 'Q10', + text: 'How do you feel about investment risk?', + category: 'volatility', + options: [ + { value: 'avoid', label: 'I want to avoid risk entirely', weight: 0 }, + { value: 'minimal', label: 'I can handle minimal risk', weight: 2 }, + { value: 'moderate', label: 'I can handle moderate risk for better returns', weight: 5 }, + { value: 'significant', label: 'I can handle significant risk', weight: 8 }, + { value: 'maximum', label: 'I seek maximum returns regardless of risk', weight: 10 }, + ], + }, + { + id: 'Q11', + text: 'How comfortable are you with your portfolio value fluctuating?', + category: 'volatility', + options: [ + { value: 'very_uncomfortable', label: 'Very uncomfortable with any decline', weight: 0 }, + { value: 'uncomfortable', label: 'Uncomfortable with declines over 5%', weight: 2 }, + { value: 'neutral', label: 'Neutral to declines of 10-15%', weight: 5 }, + { value: 'comfortable', label: 'Comfortable with declines of 20-30%', weight: 8 }, + { value: 'very_comfortable', label: 'Comfortable with declines over 30%', weight: 10 }, + ], + }, + { + id: 'Q12', + text: 'Which investment scenario would you prefer?', + category: 'volatility', + options: [ + { value: 'guaranteed', label: 'Guaranteed 3% return', weight: 0 }, + { value: 'likely_5', label: '75% chance of 5% return, 25% chance of 0%', weight: 3 }, + { value: 'likely_10', label: '50% chance of 10% return, 50% chance of -5%', weight: 6 }, + { value: 'likely_20', label: '40% chance of 20% return, 60% chance of -10%', weight: 8 }, + { value: 'likely_50', label: '25% chance of 50% return, 75% chance of -20%', weight: 10 }, + ], + }, + + // Loss Reaction (Q13-Q15) + { + id: 'Q13', + text: 'If your portfolio lost 20% in a month, what would you do?', + category: 'loss_reaction', + options: [ + { value: 'sell_all', label: 'Sell everything immediately', weight: 0 }, + { value: 'sell_some', label: 'Reduce my investment significantly', weight: 2 }, + { value: 'hold', label: 'Hold and wait for recovery', weight: 5 }, + { value: 'buy_little', label: 'Buy a little more at lower prices', weight: 8 }, + { value: 'buy_much', label: 'Buy significantly more (opportunity)', weight: 10 }, + ], + }, + { + id: 'Q14', + text: 'Have you experienced significant investment losses before?', + category: 'loss_reaction', + options: [ + { value: 'no_and_worried', label: 'No, and it worries me greatly', weight: 0 }, + { value: 'no', label: 'No, but I understand it can happen', weight: 3 }, + { value: 'yes_learned', label: 'Yes, and I learned from it', weight: 6 }, + { value: 'yes_comfortable', label: 'Yes, I am comfortable with losses', weight: 8 }, + { value: 'yes_part_of_game', label: 'Yes, losses are part of the game', weight: 10 }, + ], + }, + { + id: 'Q15', + text: 'How would you react to a 30% gain followed by a 15% loss?', + category: 'loss_reaction', + options: [ + { value: 'very_upset', label: 'Very upset about the loss', weight: 0 }, + { value: 'concerned', label: 'Concerned but still positive overall', weight: 3 }, + { value: 'neutral', label: 'Neutral, still up 15% overall', weight: 5 }, + { value: 'positive', label: 'Positive, this is normal volatility', weight: 8 }, + { value: 'opportunity', label: 'Excited about potential opportunities', weight: 10 }, + ], + }, +]; + +// ============================================================================ +// Service +// ============================================================================ + +class RiskService { + /** + * Get all questionnaire questions + */ + getQuestions(): RiskQuestion[] { + return RISK_QUESTIONS; + } + + /** + * Get user's current risk assessment + */ + async getUserAssessment(userId: string): Promise { + return await riskRepository.findByUserId(userId); + } + + /** + * Get user's valid (non-expired) assessment + */ + async getValidAssessment(userId: string): Promise { + return await riskRepository.findValidAssessment(userId); + } + + /** + * Check if user has a valid assessment + */ + async isAssessmentValid(userId: string): Promise { + const assessment = await riskRepository.findValidAssessment(userId); + return assessment !== null; + } + + /** + * Submit risk assessment and calculate profile + */ + async submitAssessment(input: SubmitAssessmentInput): Promise { + const { userId, responses, ipAddress, userAgent, completionTimeSeconds } = input; + + if (responses.length !== RISK_QUESTIONS.length) { + throw new Error( + `Invalid number of responses. Expected ${RISK_QUESTIONS.length}, got ${responses.length}` + ); + } + + const scoredResponses: RiskQuestionnaireResponse[] = []; + + for (const response of responses) { + const question = RISK_QUESTIONS.find((q) => q.id === response.questionId); + if (!question) { + throw new Error(`Invalid question ID: ${response.questionId}`); + } + + const option = question.options.find((o) => o.value === response.answer); + if (!option) { + throw new Error( + `Invalid answer "${response.answer}" for question ${response.questionId}` + ); + } + + scoredResponses.push({ + questionId: response.questionId, + answer: response.answer, + score: option.weight, + }); + } + + const calculatedScore = this.calculateRiskScore(scoredResponses); + const riskProfile = this.calculateRiskProfile(calculatedScore); + const recommendedAgent = this.recommendAgent(riskProfile); + + const assessment = await riskRepository.create({ + userId, + responses: scoredResponses, + totalScore: calculatedScore, + calculatedProfile: riskProfile, + recommendedAgent, + ipAddress, + userAgent, + completionTimeSeconds, + }); + + return assessment; + } + + /** + * Calculate total risk score from responses + */ + calculateRiskScore(responses: RiskQuestionnaireResponse[]): number { + const totalScore = responses.reduce((sum, response) => sum + response.score, 0); + return totalScore; + } + + /** + * Calculate risk profile from total score + * Conservative: 0-40 + * Moderate: 41-70 + * Aggressive: 71-150 + */ + calculateRiskProfile(totalScore: number): RiskProfile { + if (totalScore <= 40) { + return 'conservative'; + } else if (totalScore <= 70) { + return 'moderate'; + } else { + return 'aggressive'; + } + } + + /** + * Recommend trading agent based on risk profile + */ + recommendAgent(riskProfile: RiskProfile): TradingAgent { + switch (riskProfile) { + case 'conservative': + return 'atlas'; + case 'moderate': + return 'orion'; + case 'aggressive': + return 'nova'; + default: + return 'orion'; + } + } + + /** + * Get assessment history for a user + */ + async getAssessmentHistory(userId: string): Promise { + return await riskRepository.findAllByUserId(userId); + } + + /** + * Get statistics on risk profiles + */ + async getProfileStatistics(): Promise> { + return await riskRepository.countByProfile(); + } +} + +export const riskService = new RiskService(); diff --git a/src/modules/risk/types/risk.types.ts b/src/modules/risk/types/risk.types.ts new file mode 100644 index 0000000..9f82465 --- /dev/null +++ b/src/modules/risk/types/risk.types.ts @@ -0,0 +1,97 @@ +/** + * Risk Assessment Types + * Type definitions for risk questionnaire and risk profile assessment + */ + +// ============================================================================ +// Risk Enums +// ============================================================================ + +export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; + +export enum RiskProfileEnum { + CONSERVATIVE = 'conservative', + MODERATE = 'moderate', + AGGRESSIVE = 'aggressive', +} + +export type TradingAgent = 'atlas' | 'orion' | 'nova'; + +export enum TradingAgentEnum { + ATLAS = 'atlas', + ORION = 'orion', + NOVA = 'nova', +} + +// ============================================================================ +// Questionnaire Types +// ============================================================================ + +export interface RiskQuestionnaireResponse { + questionId: string; + answer: string | number; + score: number; +} + +export interface RiskAssessment { + id: string; + userId: string; + responses: RiskQuestionnaireResponse[]; + totalScore: number; + riskProfile: RiskProfile; + recommendedAgent: TradingAgent | null; + completedAt: Date; + expiresAt: Date; + isExpired: boolean; + ipAddress: string | null; + userAgent: string | null; + completionTimeSeconds: number | null; + createdAt: Date; +} + +export interface RiskQuestion { + id: string; + text: string; + category: 'experience' | 'goals' | 'timeframe' | 'volatility' | 'loss_reaction'; + options: RiskQuestionOption[]; +} + +export interface RiskQuestionOption { + value: string; + label: string; + weight: number; +} + +export interface SubmitAssessmentInput { + userId: string; + responses: Array<{ + questionId: string; + answer: string; + }>; + ipAddress?: string; + userAgent?: string; + completionTimeSeconds?: number; +} + +// ============================================================================ +// Database Row Types +// ============================================================================ + +export interface RiskQuestionnaireRow { + id: string; + user_id: string; + responses: { + question_id: string; + answer: string; + score: number; + }[]; + total_score: number; + calculated_profile: RiskProfile; + recommended_agent: TradingAgent | null; + completed_at: Date; + expires_at: Date; + ip_address: string | null; + user_agent: string | null; + completion_time_seconds: number | null; + created_at: Date; +}