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