[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:
Adrian Flores Cortes 2026-02-05 23:17:17 -06:00
parent b0a871669f
commit 60b03e063a
22 changed files with 3071 additions and 11 deletions

View File

@ -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 };

View File

@ -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',
});
}
}

View File

@ -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';

View File

@ -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
*/

View File

@ -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;
}

View File

@ -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
// ============================================================================

View File

@ -44,6 +44,8 @@ export {
disable2FA,
regenerateBackupCodes,
get2FAStatus,
validate2FA,
useBackupCode,
} from './two-factor.controller';
// Token/Session Management

View File

@ -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);
}
};

View File

@ -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;
}

View File

@ -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);
}
}

View 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,
};
}

View 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,
};
}

View 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';

View File

@ -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';

View File

@ -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 };

View File

@ -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();

View File

@ -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;
}

View File

@ -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';

View 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;
};
}

View 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);
}
}

View 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();

View File

@ -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 };