|
| 1 | +/** |
| 2 | + * Anomaly Detection REST API Controller |
| 3 | + */ |
| 4 | + |
| 5 | +import { |
| 6 | + Controller, |
| 7 | + Get, |
| 8 | + Post, |
| 9 | + Put, |
| 10 | + Body, |
| 11 | + Param, |
| 12 | + Query, |
| 13 | + HttpCode, |
| 14 | + BadRequestException, |
| 15 | + NotFoundException, |
| 16 | + ConflictException, |
| 17 | + InternalServerErrorException, |
| 18 | +} from '@nestjs/common'; |
| 19 | +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; |
| 20 | +import { |
| 21 | + OrderbookSnapshot, |
| 22 | + OrderbookEvent, |
| 23 | + MarketData, |
| 24 | + AnomalyAlert, |
| 25 | + AnomalyConfig, |
| 26 | +} from './types/order-book.types'; |
| 27 | +import { AnomalyDetectionCoordinatorService, DetectionReport } from './coordinator/anomaly-detection-coordinator.service'; |
| 28 | +import { AnomalyAlertService } from './alerts/anomaly-alert.service'; |
| 29 | + |
| 30 | +@ApiTags('Anomaly Detection') |
| 31 | +@Controller('api/anomaly-detection') |
| 32 | +export class AnomalyDetectionController { |
| 33 | + constructor( |
| 34 | + private coordinator: AnomalyDetectionCoordinatorService, |
| 35 | + private alertService: AnomalyAlertService, |
| 36 | + ) {} |
| 37 | + |
| 38 | + /** |
| 39 | + * Ingest orderbook snapshot |
| 40 | + */ |
| 41 | + @Post('orderbook/snapshot') |
| 42 | + @HttpCode(202) |
| 43 | + @ApiOperation({ summary: 'Ingest an orderbook snapshot' }) |
| 44 | + @ApiResponse({ |
| 45 | + status: 202, |
| 46 | + description: 'Snapshot ingested and detection started', |
| 47 | + }) |
| 48 | + @ApiResponse({ status: 400, description: 'Invalid request' }) |
| 49 | + async ingestSnapshot(@Body() snapshot: OrderbookSnapshot): Promise<DetectionReport> { |
| 50 | + if (!snapshot.symbol || !snapshot.bids || !snapshot.asks) { |
| 51 | + throw new BadRequestException( |
| 52 | + 'Invalid snapshot: must include symbol, bids, and asks', |
| 53 | + ); |
| 54 | + } |
| 55 | + |
| 56 | + try { |
| 57 | + return await this.coordinator.processOrderbookSnapshot(snapshot); |
| 58 | + } catch (error) { |
| 59 | + throw new InternalServerErrorException( |
| 60 | + `Failed to process snapshot: ${error.message}`, |
| 61 | + ); |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + /** |
| 66 | + * Ingest batch orderbook snapshots |
| 67 | + */ |
| 68 | + @Post('orderbook/snapshot-batch') |
| 69 | + @HttpCode(202) |
| 70 | + @ApiOperation({ summary: 'Ingest batch of orderbook snapshots' }) |
| 71 | + @ApiResponse({ status: 202, description: 'Snapshots ingested' }) |
| 72 | + async ingestSnapshotBatch( |
| 73 | + @Body() snapshots: OrderbookSnapshot[], |
| 74 | + ): Promise<DetectionReport[]> { |
| 75 | + if (!Array.isArray(snapshots) || snapshots.length === 0) { |
| 76 | + throw new BadRequestException('Must provide array of snapshots'); |
| 77 | + } |
| 78 | + |
| 79 | + const reports: DetectionReport[] = []; |
| 80 | + for (const snapshot of snapshots) { |
| 81 | + try { |
| 82 | + reports.push(await this.coordinator.processOrderbookSnapshot(snapshot)); |
| 83 | + } catch (error) { |
| 84 | + this.coordinator.processOrderbookSnapshot(snapshot).catch(() => { |
| 85 | + // Log and continue |
| 86 | + }); |
| 87 | + } |
| 88 | + } |
| 89 | + return reports; |
| 90 | + } |
| 91 | + |
| 92 | + /** |
| 93 | + * Ingest orderbook event |
| 94 | + */ |
| 95 | + @Post('orderbook/event') |
| 96 | + @HttpCode(202) |
| 97 | + @ApiOperation({ summary: 'Ingest an orderbook event (order, cancel, etc)' }) |
| 98 | + @ApiResponse({ status: 202, description: 'Event ingested' }) |
| 99 | + async ingestEvent(@Body() event: OrderbookEvent): Promise<{ success: boolean }> { |
| 100 | + if (!event.symbol || !event.orderId || !event.traderId) { |
| 101 | + throw new BadRequestException( |
| 102 | + 'Invalid event: must include symbol, orderId, and traderId', |
| 103 | + ); |
| 104 | + } |
| 105 | + |
| 106 | + try { |
| 107 | + await this.coordinator.processOrderbookEvent(event); |
| 108 | + return { success: true }; |
| 109 | + } catch (error) { |
| 110 | + throw new InternalServerErrorException(`Failed to process event: ${error.message}`); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + /** |
| 115 | + * Ingest market data |
| 116 | + */ |
| 117 | + @Post('market-data') |
| 118 | + @HttpCode(202) |
| 119 | + @ApiOperation({ summary: 'Ingest market data' }) |
| 120 | + @ApiResponse({ status: 202, description: 'Market data ingested' }) |
| 121 | + async ingestMarketData(@Body() marketData: MarketData): Promise<{ success: boolean }> { |
| 122 | + if (!marketData.symbol) { |
| 123 | + throw new BadRequestException('Market data must include symbol'); |
| 124 | + } |
| 125 | + |
| 126 | + try { |
| 127 | + await this.coordinator.processMarketData(marketData); |
| 128 | + return { success: true }; |
| 129 | + } catch (error) { |
| 130 | + throw new InternalServerErrorException( |
| 131 | + `Failed to process market data: ${error.message}`, |
| 132 | + ); |
| 133 | + } |
| 134 | + } |
| 135 | + |
| 136 | + /** |
| 137 | + * Get alerts for a symbol |
| 138 | + */ |
| 139 | + @Get('alerts/symbol/:symbol') |
| 140 | + @ApiOperation({ summary: 'Get anomaly alerts for a symbol' }) |
| 141 | + @ApiParam({ name: 'symbol', type: 'string', description: 'Trading symbol' }) |
| 142 | + @ApiResponse({ |
| 143 | + status: 200, |
| 144 | + description: 'List of alerts', |
| 145 | + type: [AnomalyAlert], |
| 146 | + }) |
| 147 | + getSymbolAlerts(@Param('symbol') symbol: string): AnomalyAlert[] { |
| 148 | + return this.coordinator.getSymbolAlerts(symbol); |
| 149 | + } |
| 150 | + |
| 151 | + /** |
| 152 | + * Get alerts for a trader |
| 153 | + */ |
| 154 | + @Get('alerts/trader/:traderId') |
| 155 | + @ApiOperation({ summary: 'Get anomaly alerts for a trader' }) |
| 156 | + @ApiParam({ name: 'traderId', type: 'string', description: 'Trader identifier' }) |
| 157 | + @ApiResponse({ |
| 158 | + status: 200, |
| 159 | + description: 'List of alerts', |
| 160 | + type: [AnomalyAlert], |
| 161 | + }) |
| 162 | + getTraderAlerts(@Param('traderId') traderId: string): AnomalyAlert[] { |
| 163 | + return this.coordinator.getTraderAlerts(traderId); |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Get a specific alert |
| 168 | + */ |
| 169 | + @Get('alerts/:alertId') |
| 170 | + @ApiOperation({ summary: 'Get a specific alert' }) |
| 171 | + @ApiParam({ name: 'alertId', type: 'string', description: 'Alert ID' }) |
| 172 | + @ApiResponse({ status: 200, description: 'Alert details' }) |
| 173 | + @ApiResponse({ status: 404, description: 'Alert not found' }) |
| 174 | + getAlert(@Param('alertId') alertId: string): AnomalyAlert { |
| 175 | + const alert = this.alertService.getAlert(alertId); |
| 176 | + if (!alert) { |
| 177 | + throw new NotFoundException(`Alert ${alertId} not found`); |
| 178 | + } |
| 179 | + return alert; |
| 180 | + } |
| 181 | + |
| 182 | + /** |
| 183 | + * Acknowledge an alert |
| 184 | + */ |
| 185 | + @Put('alerts/:alertId/acknowledge') |
| 186 | + @ApiOperation({ summary: 'Acknowledge an alert' }) |
| 187 | + @ApiParam({ name: 'alertId', type: 'string' }) |
| 188 | + @ApiResponse({ status: 200, description: 'Alert acknowledged' }) |
| 189 | + acknowledgeAlert( |
| 190 | + @Param('alertId') alertId: string, |
| 191 | + @Query('acknowledgedBy') acknowledgedBy?: string, |
| 192 | + ): { success: boolean } { |
| 193 | + const success = this.alertService.acknowledgeAlert(alertId, acknowledgedBy); |
| 194 | + if (!success) { |
| 195 | + throw new NotFoundException(`Alert ${alertId} not found`); |
| 196 | + } |
| 197 | + return { success }; |
| 198 | + } |
| 199 | + |
| 200 | + /** |
| 201 | + * Set alert status to investigating |
| 202 | + */ |
| 203 | + @Put('alerts/:alertId/investigating') |
| 204 | + @ApiOperation({ summary: 'Set alert status to investigating' }) |
| 205 | + @ApiParam({ name: 'alertId', type: 'string' }) |
| 206 | + @ApiResponse({ status: 200, description: 'Status updated' }) |
| 207 | + setInvestigating(@Param('alertId') alertId: string): { success: boolean } { |
| 208 | + const success = this.alertService.setAlertInvestigating(alertId); |
| 209 | + if (!success) { |
| 210 | + throw new NotFoundException(`Alert ${alertId} not found`); |
| 211 | + } |
| 212 | + return { success }; |
| 213 | + } |
| 214 | + |
| 215 | + /** |
| 216 | + * Resolve an alert |
| 217 | + */ |
| 218 | + @Put('alerts/:alertId/resolve') |
| 219 | + @ApiOperation({ summary: 'Resolve an alert' }) |
| 220 | + @ApiParam({ name: 'alertId', type: 'string' }) |
| 221 | + @ApiQuery({ |
| 222 | + name: 'as', |
| 223 | + enum: ['RESOLVED', 'FALSE_POSITIVE'], |
| 224 | + description: 'Resolution type', |
| 225 | + }) |
| 226 | + @ApiResponse({ status: 200, description: 'Alert resolved' }) |
| 227 | + resolveAlert( |
| 228 | + @Param('alertId') alertId: string, |
| 229 | + @Query('as') resolution: 'RESOLVED' | 'FALSE_POSITIVE' = 'RESOLVED', |
| 230 | + ): { success: boolean } { |
| 231 | + const success = this.alertService.resolveAlert(alertId, resolution); |
| 232 | + if (!success) { |
| 233 | + throw new NotFoundException(`Alert ${alertId} not found`); |
| 234 | + } |
| 235 | + return { success }; |
| 236 | + } |
| 237 | + |
| 238 | + /** |
| 239 | + * Execute an action on an alert |
| 240 | + */ |
| 241 | + @Post('alerts/:alertId/action') |
| 242 | + @ApiOperation({ summary: 'Execute an action on an alert' }) |
| 243 | + @ApiParam({ name: 'alertId', type: 'string' }) |
| 244 | + @ApiResponse({ |
| 245 | + status: 200, |
| 246 | + description: 'Action executed', |
| 247 | + }) |
| 248 | + executeAction( |
| 249 | + @Param('alertId') alertId: string, |
| 250 | + @Query('type') |
| 251 | + actionType: |
| 252 | + | 'THROTTLE' |
| 253 | + | 'BLOCK' |
| 254 | + | 'AUTO_BAN' |
| 255 | + | 'REVIEW' |
| 256 | + | 'ESCALATE' = 'THROTTLE', |
| 257 | + @Body() parameters?: Record<string, any>, |
| 258 | + ): { success: boolean; result?: string } { |
| 259 | + const action = this.alertService.executeAction(alertId, actionType, parameters); |
| 260 | + if (!action) { |
| 261 | + throw new NotFoundException(`Alert ${alertId} not found or invalid action`); |
| 262 | + } |
| 263 | + return { success: action.status === 'EXECUTED', result: action.result }; |
| 264 | + } |
| 265 | + |
| 266 | + /** |
| 267 | + * Get alert statistics |
| 268 | + */ |
| 269 | + @Get('statistics') |
| 270 | + @ApiOperation({ summary: 'Get alert statistics' }) |
| 271 | + @ApiQuery({ name: 'days', type: 'number', description: 'Period in days' }) |
| 272 | + @ApiResponse({ status: 200, description: 'Statistics' }) |
| 273 | + getStatistics(@Query('days') days: number = 7): any { |
| 274 | + return this.coordinator.getAlertStatistics(days); |
| 275 | + } |
| 276 | + |
| 277 | + /** |
| 278 | + * Get anomaly detection configuration |
| 279 | + */ |
| 280 | + @Get('config') |
| 281 | + @ApiOperation({ summary: 'Get current configuration' }) |
| 282 | + @ApiResponse({ status: 200, description: 'Configuration' }) |
| 283 | + getConfig(): AnomalyConfig { |
| 284 | + return this.coordinator.getConfig(); |
| 285 | + } |
| 286 | + |
| 287 | + /** |
| 288 | + * Update anomaly detection configuration |
| 289 | + */ |
| 290 | + @Put('config') |
| 291 | + @ApiOperation({ summary: 'Update configuration' }) |
| 292 | + @ApiResponse({ status: 200, description: 'Configuration updated' }) |
| 293 | + updateConfig(@Body() config: Partial<AnomalyConfig>): { success: boolean } { |
| 294 | + this.coordinator.updateConfig(config); |
| 295 | + return { success: true }; |
| 296 | + } |
| 297 | + |
| 298 | + /** |
| 299 | + * Check if a trader is throttled |
| 300 | + */ |
| 301 | + @Get('throttle/:traderId') |
| 302 | + @ApiOperation({ summary: 'Check throttle status for a trader' }) |
| 303 | + @ApiParam({ name: 'traderId', type: 'string' }) |
| 304 | + @ApiResponse({ status: 200, description: 'Throttle info' }) |
| 305 | + getThrottleStatus(@Param('traderId') traderId: string): { |
| 306 | + isThrottled: boolean; |
| 307 | + until?: number; |
| 308 | + reason?: string; |
| 309 | + } { |
| 310 | + const throttleInfo = this.alertService.getThrottleInfo(traderId); |
| 311 | + if (throttleInfo) { |
| 312 | + return { |
| 313 | + isThrottled: true, |
| 314 | + until: throttleInfo.until, |
| 315 | + reason: throttleInfo.reason, |
| 316 | + }; |
| 317 | + } |
| 318 | + return { isThrottled: false }; |
| 319 | + } |
| 320 | + |
| 321 | + /** |
| 322 | + * Export alerts as CSV or JSON |
| 323 | + */ |
| 324 | + @Get('export') |
| 325 | + @ApiOperation({ summary: 'Export alerts' }) |
| 326 | + @ApiQuery({ |
| 327 | + name: 'format', |
| 328 | + enum: ['json', 'csv'], |
| 329 | + default: 'json', |
| 330 | + }) |
| 331 | + @ApiResponse({ status: 200, description: 'Exported data' }) |
| 332 | + exportAlerts(@Query('format') format: 'json' | 'csv' = 'json'): any { |
| 333 | + const data = this.coordinator.exportAuditTrail(format); |
| 334 | + if (format === 'csv') { |
| 335 | + return { csv: data }; |
| 336 | + } |
| 337 | + return JSON.parse(data); |
| 338 | + } |
| 339 | + |
| 340 | + /** |
| 341 | + * Run backtest on historical data |
| 342 | + */ |
| 343 | + @Post('backtest') |
| 344 | + @HttpCode(200) |
| 345 | + @ApiOperation({ summary: 'Run backtest on historical data' }) |
| 346 | + @ApiResponse({ |
| 347 | + status: 200, |
| 348 | + description: 'Backtest results', |
| 349 | + }) |
| 350 | + async runBacktest( |
| 351 | + @Body() |
| 352 | + request: { |
| 353 | + symbol: string; |
| 354 | + startTime: number; |
| 355 | + endTime: number; |
| 356 | + snapshots: OrderbookSnapshot[]; |
| 357 | + events: OrderbookEvent[]; |
| 358 | + }, |
| 359 | + ): Promise<any> { |
| 360 | + if ( |
| 361 | + !request.symbol || |
| 362 | + typeof request.startTime !== 'number' || |
| 363 | + typeof request.endTime !== 'number' |
| 364 | + ) { |
| 365 | + throw new BadRequestException( |
| 366 | + 'Invalid request: must include symbol, startTime, endTime', |
| 367 | + ); |
| 368 | + } |
| 369 | + |
| 370 | + if (request.startTime >= request.endTime) { |
| 371 | + throw new BadRequestException('startTime must be before endTime'); |
| 372 | + } |
| 373 | + |
| 374 | + try { |
| 375 | + return await this.coordinator.runBacktest( |
| 376 | + request.symbol, |
| 377 | + request.startTime, |
| 378 | + request.endTime, |
| 379 | + request.snapshots || [], |
| 380 | + request.events || [], |
| 381 | + ); |
| 382 | + } catch (error) { |
| 383 | + throw new InternalServerErrorException(`Backtest failed: ${error.message}`); |
| 384 | + } |
| 385 | + } |
| 386 | + |
| 387 | + /** |
| 388 | + * Health check |
| 389 | + */ |
| 390 | + @Get('health') |
| 391 | + @ApiOperation({ summary: 'Health check' }) |
| 392 | + @ApiResponse({ status: 200, description: 'Service is healthy' }) |
| 393 | + health(): { status: string; timestamp: number } { |
| 394 | + return { |
| 395 | + status: 'healthy', |
| 396 | + timestamp: Date.now(), |
| 397 | + }; |
| 398 | + } |
| 399 | +} |
0 commit comments