[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)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 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 };
|
||||
|
||||
@ -11,6 +11,8 @@ import type {
|
||||
AuditLogFilters,
|
||||
SecurityEventFilters,
|
||||
ComplianceLogFilters,
|
||||
AuditSearchQuery,
|
||||
AuditEventInput,
|
||||
} from '../types/audit.types';
|
||||
|
||||
/**
|
||||
@ -326,3 +328,209 @@ export async function createSecurityEvent(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Simplified Event Endpoints (per task requirements)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/v1/audit/events
|
||||
* Get all events with filters (admin only)
|
||||
* Query params: limit, offset
|
||||
*/
|
||||
export async function getEvents(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<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 { auditService } from './services/audit.service';
|
||||
|
||||
// Export all types
|
||||
export * from './types/audit.types';
|
||||
|
||||
// Re-export commonly used types for convenience
|
||||
export type {
|
||||
AuditEvent,
|
||||
AuditEventInput,
|
||||
AuditSearchQuery,
|
||||
AuditQueryOptions,
|
||||
CommonEventType,
|
||||
} from './types/audit.types';
|
||||
|
||||
@ -18,6 +18,10 @@ import type {
|
||||
AuditStats,
|
||||
AuditEventType,
|
||||
EventSeverity,
|
||||
AuditEvent,
|
||||
AuditEventInput,
|
||||
AuditSearchQuery,
|
||||
AuditQueryOptions,
|
||||
} from '../types/audit.types';
|
||||
|
||||
interface AuditLogRow {
|
||||
@ -564,6 +568,251 @@ class AuditService {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Simplified Event Methods (per task requirements)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Log a simple event (simplified interface)
|
||||
* Maps to the full logAction method internally
|
||||
*/
|
||||
async logEvent(event: AuditEventInput): Promise<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
|
||||
*/
|
||||
|
||||
@ -520,3 +520,94 @@ export interface DataAccessLogFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Simplified Event Types (per task requirements)
|
||||
// These are simplified aliases for common audit operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common event types for audit logging
|
||||
*/
|
||||
export type CommonEventType =
|
||||
| 'auth.login'
|
||||
| 'auth.logout'
|
||||
| 'auth.password_change'
|
||||
| 'auth.2fa_enabled'
|
||||
| 'auth.2fa_disabled'
|
||||
| 'profile.update'
|
||||
| 'profile.avatar_change'
|
||||
| 'trading.order'
|
||||
| 'trading.position_open'
|
||||
| 'trading.position_close'
|
||||
| 'payment.initiated'
|
||||
| 'payment.completed'
|
||||
| 'payment.failed'
|
||||
| 'subscription.created'
|
||||
| 'subscription.cancelled'
|
||||
| 'course.enrolled'
|
||||
| 'course.completed'
|
||||
| 'bot.started'
|
||||
| 'bot.stopped'
|
||||
| 'api.key_created'
|
||||
| 'api.key_revoked';
|
||||
|
||||
/**
|
||||
* Simplified AuditEvent interface (alias for AuditLog)
|
||||
* Matches the structure requested in the task
|
||||
*/
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
eventType: string;
|
||||
action: string;
|
||||
resourceType: string | null;
|
||||
resourceId: string | null;
|
||||
metadata: Record<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 { authRateLimiter, strictRateLimiter, refreshTokenRateLimiter } from '../../core/middleware/rate-limiter';
|
||||
import { authenticate } from '../../core/middleware/auth.middleware';
|
||||
import * as authController from './controllers/auth.controller';
|
||||
import * as authController from './controllers';
|
||||
import * as validators from './validators/auth.validators';
|
||||
|
||||
const router = Router();
|
||||
@ -271,6 +271,34 @@ router.post(
|
||||
authController.regenerateBackupCodes
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/2fa/status
|
||||
* Get 2FA status for authenticated user
|
||||
*/
|
||||
router.get('/2fa/status', authenticate, authController.get2FAStatus);
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/2fa/validate
|
||||
* Validate 2FA code during login (second step of two-step login)
|
||||
*/
|
||||
router.post(
|
||||
'/2fa/validate',
|
||||
validators.totpCodeValidator,
|
||||
validate,
|
||||
authController.validate2FA
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/2fa/backup-codes/use
|
||||
* Use a backup code during login
|
||||
*/
|
||||
router.post(
|
||||
'/2fa/backup-codes/use',
|
||||
validators.totpCodeValidator,
|
||||
validate,
|
||||
authController.useBackupCode
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Account Linking
|
||||
// ============================================================================
|
||||
|
||||
@ -44,6 +44,8 @@ export {
|
||||
disable2FA,
|
||||
regenerateBackupCodes,
|
||||
get2FAStatus,
|
||||
validate2FA,
|
||||
useBackupCode,
|
||||
} from './two-factor.controller';
|
||||
|
||||
// Token/Session Management
|
||||
|
||||
@ -9,12 +9,18 @@
|
||||
* - POST /auth/2fa/enable - Enable 2FA with verification code
|
||||
* - POST /auth/2fa/disable - Disable 2FA with verification code
|
||||
* - POST /auth/2fa/backup-codes - Regenerate backup codes
|
||||
* - GET /auth/2fa/status - Get 2FA status
|
||||
* - POST /auth/2fa/validate - Validate 2FA code during login
|
||||
* - POST /auth/2fa/backup-codes/use - Use backup code during login
|
||||
*
|
||||
* @see EmailAuthController - Email/password authentication (handles 2FA during login)
|
||||
* @see TokenController - Token management
|
||||
*/
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { twoFactorService } from '../services/twofa.service';
|
||||
import { tokenService } from '../services/token.service';
|
||||
import { db } from '../../../shared/database';
|
||||
import type { User } from '../types/auth.types';
|
||||
|
||||
/**
|
||||
* POST /auth/2fa/setup
|
||||
@ -122,3 +128,147 @@ export const get2FAStatus = async (req: Request, res: Response, next: NextFuncti
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /auth/2fa/validate
|
||||
*
|
||||
* Validate 2FA code during two-step login process.
|
||||
* Called after initial login returns requiresTwoFactor: true.
|
||||
* Requires pendingUserId from session/cookie or request body.
|
||||
*/
|
||||
export const validate2FA = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { code, userId: pendingUserId } = req.body;
|
||||
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'User ID is required for 2FA validation',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the 2FA code
|
||||
const valid = await twoFactorService.verifyTOTP(pendingUserId, code);
|
||||
|
||||
if (!valid) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid verification code',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user to create session
|
||||
const userResult = await db.query<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[];
|
||||
}
|
||||
|
||||
export interface TwoFAStatus {
|
||||
enabled: boolean;
|
||||
method: '2fa_totp' | null;
|
||||
backupCodesRemaining: number;
|
||||
enabledAt?: Date;
|
||||
lastUsedAt?: Date;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Market Data Controller
|
||||
* Handles OHLCV data endpoints
|
||||
* Handles OHLCV data and Ticker endpoints
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { marketDataService } from '../services/marketData.service';
|
||||
import { Timeframe } from '../types/market-data.types';
|
||||
import { Timeframe, AssetType } from '../types/market-data.types';
|
||||
import { logger } from '../../../shared/utils/logger';
|
||||
import { isValidSymbol } from '../dto/get-ohlcv.dto';
|
||||
import { VALID_ASSET_TYPES, isValidAssetType } from '../dto/get-ticker.dto';
|
||||
|
||||
export async function getOHLCV(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
@ -156,3 +158,176 @@ export async function healthCheck(req: Request, res: Response, next: NextFunctio
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Ticker Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/market-data/ticker/:symbol
|
||||
* Get real-time ticker data for a specific symbol
|
||||
*/
|
||||
export async function getTicker(req: Request, res: Response, next: NextFunction): Promise<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
|
||||
* Exports market data service, routes, and types
|
||||
* Provides OHLCV data and real-time ticker information
|
||||
*
|
||||
* Features:
|
||||
* - OHLCV candles (5m, 15m, 1h, 4h, 1d timeframes)
|
||||
* - Real-time ticker data with 24h statistics
|
||||
* - Latest price lookup
|
||||
* - Redis caching with configurable TTLs
|
||||
* - PostgreSQL data storage
|
||||
*
|
||||
* @module market-data
|
||||
*/
|
||||
|
||||
// Service
|
||||
export { marketDataService } from './services/marketData.service';
|
||||
|
||||
// Routes
|
||||
export { marketDataRouter } from './market-data.routes';
|
||||
|
||||
// Types
|
||||
export * from './types/market-data.types';
|
||||
|
||||
// DTOs
|
||||
export * from './dto';
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
/**
|
||||
* Market Data Routes
|
||||
* Defines REST endpoints for OHLCV market data
|
||||
* Defines REST endpoints for OHLCV market data and Tickers
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/market-data/health - Service health check
|
||||
* - GET /api/market-data/symbols - List available symbols
|
||||
* - GET /api/market-data/ohlcv/:symbol/:timeframe - Get OHLCV candles
|
||||
* - GET /api/market-data/ohlcv/:symbol - Get OHLCV (default 5m timeframe)
|
||||
* - GET /api/market-data/historical/:symbol - Get historical data range
|
||||
* - GET /api/market-data/ticker/:symbol - Get real-time ticker
|
||||
* - GET /api/market-data/tickers - Get all tickers
|
||||
* - GET /api/market-data/price/:symbol - Get latest price
|
||||
* - GET /api/market-data/ticker-info/:symbol - Get ticker metadata
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
@ -9,16 +20,88 @@ import {
|
||||
getHistoricalData,
|
||||
getAvailableSymbols,
|
||||
healthCheck,
|
||||
getTicker,
|
||||
getAllTickers,
|
||||
getLatestPrice,
|
||||
getTickerInfo,
|
||||
} from './controllers/market-data.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// =============================================================================
|
||||
// Health & Info
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/market-data/health
|
||||
* Service health check
|
||||
*/
|
||||
router.get('/health', healthCheck);
|
||||
|
||||
/**
|
||||
* GET /api/market-data/symbols
|
||||
* Get list of available symbols
|
||||
*/
|
||||
router.get('/symbols', getAvailableSymbols);
|
||||
|
||||
// =============================================================================
|
||||
// OHLCV Endpoints
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/market-data/ohlcv/:symbol/:timeframe
|
||||
* Get OHLCV candles for a symbol and timeframe
|
||||
* Query params: limit (optional, default 100, max 1000)
|
||||
*/
|
||||
router.get('/ohlcv/:symbol/:timeframe', getOHLCV);
|
||||
|
||||
/**
|
||||
* GET /api/market-data/ohlcv/:symbol
|
||||
* Get OHLCV candles with default 5m timeframe
|
||||
* Query params: limit (optional, default 100, max 1000)
|
||||
*/
|
||||
router.get('/ohlcv/:symbol', (req, res, next) => {
|
||||
// Cast to allow adding timeframe param
|
||||
(req.params as Record<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);
|
||||
|
||||
// =============================================================================
|
||||
// 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 };
|
||||
|
||||
@ -10,10 +10,12 @@ import { logger } from '../../../shared/utils/logger';
|
||||
import {
|
||||
OHLCV,
|
||||
Timeframe,
|
||||
CandleQueryOptions,
|
||||
HistoricalDataOptions,
|
||||
OhlcvDataRow,
|
||||
TickerRow,
|
||||
Ticker,
|
||||
TickerInfo,
|
||||
LatestPrice,
|
||||
AssetType,
|
||||
} from '../types/market-data.types';
|
||||
|
||||
// =============================================================================
|
||||
@ -28,6 +30,13 @@ const CACHE_TTL_BY_TIMEFRAME: Record<Timeframe, number> = {
|
||||
'1d': 3600, // 1 hour cache
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Cache TTL for Ticker Data (shorter for real-time feel)
|
||||
// =============================================================================
|
||||
|
||||
const CACHE_TTL_TICKER = 5; // 5 seconds for ticker data
|
||||
const CACHE_TTL_PRICE = 5; // 5 seconds for latest price
|
||||
|
||||
// =============================================================================
|
||||
// Timeframe Minutes Mapping
|
||||
// =============================================================================
|
||||
@ -420,6 +429,320 @@ class MarketDataService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Ticker Methods - Real-time market data
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get ticker data for a specific symbol
|
||||
* Calculates real-time stats from OHLCV data
|
||||
* Cache TTL: 5 seconds
|
||||
*/
|
||||
async getTicker(symbol: string): Promise<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();
|
||||
|
||||
@ -125,3 +125,50 @@ export interface OhlcvStagingRow {
|
||||
* @deprecated Use Ohlcv5mRow instead
|
||||
*/
|
||||
export type OhlcvDataRow = Ohlcv5mRow;
|
||||
|
||||
// =============================================================================
|
||||
// Ticker Types - Real-time market data derived from OHLCV
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ticker data representing current market state for a symbol
|
||||
* Derived from latest OHLCV candles and 24h calculations
|
||||
*/
|
||||
export interface Ticker {
|
||||
symbol: string;
|
||||
bid: number;
|
||||
ask: number;
|
||||
last: number;
|
||||
volume24h: number;
|
||||
change24h: number;
|
||||
changePercent24h: number;
|
||||
high24h: number;
|
||||
low24h: number;
|
||||
open24h: number;
|
||||
vwap24h?: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified ticker info from the catalog
|
||||
*/
|
||||
export interface TickerInfo {
|
||||
symbol: string;
|
||||
name: string;
|
||||
assetType: AssetType;
|
||||
baseCurrency: string;
|
||||
quoteCurrency: string;
|
||||
isMlEnabled: boolean;
|
||||
supportedTimeframes: string[];
|
||||
polygonTicker: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest price response
|
||||
*/
|
||||
export interface LatestPrice {
|
||||
symbol: string;
|
||||
price: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@ -1,7 +1,34 @@
|
||||
/**
|
||||
* Notifications Module
|
||||
* Exports notification service, routes, and types
|
||||
* Unified notification system supporting push, email, in-app, and WebSocket delivery
|
||||
*
|
||||
* Features:
|
||||
* - Multi-channel delivery (push, email, in-app, SMS)
|
||||
* - User preferences management
|
||||
* - Quiet hours support
|
||||
* - Firebase Cloud Messaging integration
|
||||
* - WebSocket real-time notifications
|
||||
* - Email templates for each notification type
|
||||
*
|
||||
* @module notifications
|
||||
*/
|
||||
|
||||
export * from './services/notification.service';
|
||||
export * from './notification.routes';
|
||||
// Service
|
||||
export { notificationService } from './services/notification.service';
|
||||
|
||||
// Routes
|
||||
export { notificationRouter } from './notification.routes';
|
||||
|
||||
// Types
|
||||
export * from './types/notifications.types';
|
||||
|
||||
// Re-export service types for backwards compatibility
|
||||
export type {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
DeliveryChannel,
|
||||
NotificationPayload,
|
||||
Notification,
|
||||
UserNotificationPreferences,
|
||||
SendNotificationOptions,
|
||||
} from './services/notification.service';
|
||||
|
||||
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
|
||||
* Market data, paper trading, and watchlist endpoints
|
||||
* Market data, paper trading, watchlist, and bots endpoints
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
@ -9,6 +9,7 @@ import * as watchlistController from './controllers/watchlist.controller';
|
||||
import * as indicatorsController from './controllers/indicators.controller';
|
||||
import * as alertsController from './controllers/alerts.controller';
|
||||
import * as exportController from './controllers/export.controller';
|
||||
import * as botsController from './controllers/bots.controller';
|
||||
import { requireAuth } from '../../core/guards/auth.guard';
|
||||
|
||||
const router = Router();
|
||||
@ -397,4 +398,80 @@ router.post('/alerts/:alertId/enable', authHandler(requireAuth), authHandler(ale
|
||||
*/
|
||||
router.post('/alerts/:alertId/disable', authHandler(requireAuth), authHandler(alertsController.disableAlert));
|
||||
|
||||
// ============================================================================
|
||||
// Trading Bots Routes (Authenticated)
|
||||
// Atlas (trend following), Orion (mean reversion), Nova (breakout)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots/templates
|
||||
* Get available bot templates (Atlas, Orion, Nova)
|
||||
* IMPORTANT: This route must be before /bots/:botId to avoid param conflict
|
||||
*/
|
||||
router.get('/bots/templates', requireAuth, authHandler(botsController.getTemplates));
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots/templates/:type
|
||||
* Get a specific bot template
|
||||
*/
|
||||
router.get('/bots/templates/:type', requireAuth, authHandler(botsController.getTemplateByType));
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots
|
||||
* Get all bots for the authenticated user
|
||||
* Query: status, botType, strategyType, limit, offset
|
||||
*/
|
||||
router.get('/bots', requireAuth, authHandler(botsController.getBots));
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots/:botId
|
||||
* Get a specific bot by ID
|
||||
*/
|
||||
router.get('/bots/:botId', requireAuth, authHandler(botsController.getBotById));
|
||||
|
||||
/**
|
||||
* POST /api/v1/trading/bots
|
||||
* Create a new trading bot
|
||||
* Body: { name, botType?, symbols, timeframe?, initialCapital, strategyType?, strategyConfig? }
|
||||
*/
|
||||
router.post('/bots', requireAuth, authHandler(botsController.createBot));
|
||||
|
||||
/**
|
||||
* PUT /api/v1/trading/bots/:botId
|
||||
* Update bot configuration
|
||||
* Body: { name?, status?, symbols?, timeframe?, strategyType?, strategyConfig?, maxPositionSizePct?, maxDailyLossPct?, maxDrawdownPct? }
|
||||
*/
|
||||
router.put('/bots/:botId', requireAuth, authHandler(botsController.updateBot));
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/trading/bots/:botId
|
||||
* Delete a bot (must be stopped first)
|
||||
*/
|
||||
router.delete('/bots/:botId', requireAuth, authHandler(botsController.deleteBot));
|
||||
|
||||
/**
|
||||
* POST /api/v1/trading/bots/:botId/start
|
||||
* Start a bot
|
||||
*/
|
||||
router.post('/bots/:botId/start', requireAuth, authHandler(botsController.startBot));
|
||||
|
||||
/**
|
||||
* POST /api/v1/trading/bots/:botId/stop
|
||||
* Stop a bot
|
||||
*/
|
||||
router.post('/bots/:botId/stop', requireAuth, authHandler(botsController.stopBot));
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots/:botId/performance
|
||||
* Get bot performance metrics
|
||||
*/
|
||||
router.get('/bots/:botId/performance', requireAuth, authHandler(botsController.getBotPerformance));
|
||||
|
||||
/**
|
||||
* GET /api/v1/trading/bots/:botId/executions
|
||||
* Get bot execution history
|
||||
* Query: limit, offset
|
||||
*/
|
||||
router.get('/bots/:botId/executions', requireAuth, authHandler(botsController.getBotExecutions));
|
||||
|
||||
export { router as tradingRouter };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user