feat(proxy): Add Express proxy gateway for Python services (ARCH-001)
- Add proxy module with types, service, controller, and routes - Configure llmAgent and dataService in config - Register proxy routes in main Express app - All Python service access now goes through authenticated Express gateway ARCH-001: Centralized proxy with auth, logging, and error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b99953bf3e
commit
58a7b44673
@ -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 || '',
|
||||
|
||||
@ -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);
|
||||
|
||||
97
src/modules/audit/audit.routes.ts
Normal file
97
src/modules/audit/audit.routes.ts
Normal file
@ -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 };
|
||||
328
src/modules/audit/controllers/audit.controller.ts
Normal file
328
src/modules/audit/controllers/audit.controller.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
8
src/modules/audit/index.ts
Normal file
8
src/modules/audit/index.ts
Normal file
@ -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';
|
||||
653
src/modules/audit/services/audit.service.ts
Normal file
653
src/modules/audit/services/audit.service.ts
Normal file
@ -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<AuditLog[]> {
|
||||
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<AuditLogRow>(
|
||||
`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<AuditLog[]> {
|
||||
return this.getAuditLogs({ userId, limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new audit log entry
|
||||
*/
|
||||
async logAction(data: CreateAuditLogInput): Promise<AuditLog> {
|
||||
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<AuditLogRow>(
|
||||
`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<SecurityEvent[]> {
|
||||
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<SecurityEventRow>(
|
||||
`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<SecurityEvent> {
|
||||
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<SecurityEventRow>(
|
||||
`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<ComplianceLog[]> {
|
||||
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<ComplianceLogRow>(
|
||||
`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<ComplianceLog> {
|
||||
const {
|
||||
regulation,
|
||||
requirement,
|
||||
eventType,
|
||||
eventDescription,
|
||||
userId,
|
||||
systemInitiated = false,
|
||||
complianceStatus,
|
||||
riskLevel,
|
||||
evidence,
|
||||
remediationRequired = false,
|
||||
remediationDeadline,
|
||||
remediationNotes,
|
||||
metadata = {},
|
||||
} = data;
|
||||
|
||||
const result = await db.query<ComplianceLogRow>(
|
||||
`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<AuditStats> {
|
||||
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<AuditEventType, number>,
|
||||
bySeverity: {} as Record<EventSeverity, number>,
|
||||
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();
|
||||
239
src/modules/audit/types/audit.types.ts
Normal file
239
src/modules/audit/types/audit.types.ts
Normal file
@ -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<string, unknown> | null;
|
||||
newValues: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
newValues?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSecurityEventInput {
|
||||
category: SecurityEventCategory;
|
||||
severity: EventSeverity;
|
||||
eventStatus?: EventStatus;
|
||||
userId?: string;
|
||||
ipAddress: string;
|
||||
userAgent?: string;
|
||||
geoLocation?: Record<string, unknown>;
|
||||
eventCode: string;
|
||||
eventName: string;
|
||||
description?: string;
|
||||
requestPath?: string;
|
||||
requestMethod?: string;
|
||||
responseCode?: number;
|
||||
riskScore?: number;
|
||||
isBlocked?: boolean;
|
||||
blockReason?: string;
|
||||
requiresReview?: boolean;
|
||||
rawData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> | null;
|
||||
remediationRequired: boolean;
|
||||
remediationDeadline: Date | null;
|
||||
remediationNotes: string | null;
|
||||
reviewedBy: string | null;
|
||||
reviewedAt: Date | null;
|
||||
metadata: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
remediationRequired?: boolean;
|
||||
remediationDeadline?: Date;
|
||||
remediationNotes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<AuditEventType, number>;
|
||||
bySeverity: Record<EventSeverity, number>;
|
||||
criticalEvents: number;
|
||||
}
|
||||
414
src/modules/proxy/controllers/proxy.controller.ts
Normal file
414
src/modules/proxy/controllers/proxy.controller.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const health = await proxyService.checkMLHealth();
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
}
|
||||
|
||||
async getLLMHealth(_req: Request, res: Response): Promise<void> {
|
||||
const health = await proxyService.checkLLMHealth();
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
}
|
||||
|
||||
async getDataServiceHealth(_req: Request, res: Response): Promise<void> {
|
||||
const health = await proxyService.checkDataServiceHealth();
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
}
|
||||
|
||||
async getAllServicesHealth(_req: Request, res: Response): Promise<void> {
|
||||
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();
|
||||
7
src/modules/proxy/index.ts
Normal file
7
src/modules/proxy/index.ts
Normal file
@ -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';
|
||||
89
src/modules/proxy/proxy.routes.ts
Normal file
89
src/modules/proxy/proxy.routes.ts
Normal file
@ -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;
|
||||
444
src/modules/proxy/services/proxy.service.ts
Normal file
444
src/modules/proxy/services/proxy.service.ts
Normal file
@ -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<T>(
|
||||
baseUrl: string,
|
||||
options: ProxyRequestOptions
|
||||
): Promise<ProxyResponse<T>> {
|
||||
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<T>(options: ProxyRequestOptions): Promise<ProxyResponse<T>> {
|
||||
return this.makeRequest<T>(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<string, unknown> }) {
|
||||
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<string, string>).toString()}`
|
||||
: '';
|
||||
return this.mlRequest({
|
||||
method: 'GET',
|
||||
path: `/api/predictions/history/${symbol}${query}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LLM Agent Proxy ==========
|
||||
|
||||
async llmRequest<T>(options: ProxyRequestOptions): Promise<ProxyResponse<T>> {
|
||||
return this.makeRequest<T>(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<string, unknown> }) {
|
||||
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<string, unknown>;
|
||||
}) {
|
||||
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<string, string>).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<T>(options: ProxyRequestOptions): Promise<ProxyResponse<T>> {
|
||||
return this.makeRequest<T>(proxyConfig.dataServiceUrl, options);
|
||||
}
|
||||
|
||||
async getHistoricalCandles(
|
||||
symbol: string,
|
||||
params?: { timeframe?: string; start?: string; end?: string; limit?: number }
|
||||
) {
|
||||
const query = params
|
||||
? `?${new URLSearchParams(params as Record<string, string>).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<ServiceHealth> {
|
||||
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<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
service: 'ml-engine',
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkLLMHealth(): Promise<ServiceHealth> {
|
||||
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<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
service: 'llm-agent',
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkDataServiceHealth(): Promise<ServiceHealth> {
|
||||
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<string, unknown>,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
service: 'data-service',
|
||||
status: 'unhealthy',
|
||||
latency: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async checkAllServicesHealth(): Promise<ServiceHealth[]> {
|
||||
const [ml, llm, data] = await Promise.all([
|
||||
this.checkMLHealth(),
|
||||
this.checkLLMHealth(),
|
||||
this.checkDataServiceHealth(),
|
||||
]);
|
||||
return [ml, llm, data];
|
||||
}
|
||||
}
|
||||
|
||||
export const proxyService = new ProxyService();
|
||||
178
src/modules/proxy/types/proxy.types.ts
Normal file
178
src/modules/proxy/types/proxy.types.ts
Normal file
@ -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<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ProxyResponse<T = unknown> {
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
266
src/modules/risk/controllers/risk.controller.ts
Normal file
266
src/modules/risk/controllers/risk.controller.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const statistics = await riskService.getProfileStatistics();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
10
src/modules/risk/index.ts
Normal file
10
src/modules/risk/index.ts
Normal file
@ -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';
|
||||
272
src/modules/risk/repositories/risk.repository.ts
Normal file
272
src/modules/risk/repositories/risk.repository.ts
Normal file
@ -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<RiskAssessment | null> {
|
||||
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<RiskQuestionnaireRow>(query, [userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapRowToAssessment(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find assessment by ID
|
||||
*/
|
||||
async findById(id: string): Promise<RiskAssessment | null> {
|
||||
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<RiskQuestionnaireRow>(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<RiskAssessment | null> {
|
||||
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<RiskQuestionnaireRow>(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<RiskAssessment> {
|
||||
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<RiskQuestionnaireRow>(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<RiskAssessment[]> {
|
||||
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<RiskQuestionnaireRow>(query, [userId]);
|
||||
|
||||
return result.rows.map(mapRowToAssessment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count assessments by risk profile
|
||||
*/
|
||||
async countByProfile(): Promise<Record<RiskProfile, number>> {
|
||||
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<RiskProfile, number> = {
|
||||
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();
|
||||
72
src/modules/risk/risk.routes.ts
Normal file
72
src/modules/risk/risk.routes.ts
Normal file
@ -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 };
|
||||
353
src/modules/risk/services/risk.service.ts
Normal file
353
src/modules/risk/services/risk.service.ts
Normal file
@ -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<RiskAssessment | null> {
|
||||
return await riskRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's valid (non-expired) assessment
|
||||
*/
|
||||
async getValidAssessment(userId: string): Promise<RiskAssessment | null> {
|
||||
return await riskRepository.findValidAssessment(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a valid assessment
|
||||
*/
|
||||
async isAssessmentValid(userId: string): Promise<boolean> {
|
||||
const assessment = await riskRepository.findValidAssessment(userId);
|
||||
return assessment !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit risk assessment and calculate profile
|
||||
*/
|
||||
async submitAssessment(input: SubmitAssessmentInput): Promise<RiskAssessment> {
|
||||
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<RiskAssessment[]> {
|
||||
return await riskRepository.findAllByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics on risk profiles
|
||||
*/
|
||||
async getProfileStatistics(): Promise<Record<RiskProfile, number>> {
|
||||
return await riskRepository.countByProfile();
|
||||
}
|
||||
}
|
||||
|
||||
export const riskService = new RiskService();
|
||||
97
src/modules/risk/types/risk.types.ts
Normal file
97
src/modules/risk/types/risk.types.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user