import express, { NextFunction, Request, Response } from 'express'; import { createServer } from 'http'; import { Server, Socket } from 'socket.io'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; import logger from '../utils/logger.js'; import fs from 'fs'; import path from 'path'; import { ManualTrader } from './ManualTrader.js'; import { applyDynamicConfigEntries, config, loadDynamicConfig } from '../config/index.js'; import { AIClient } from './aiClient.js'; import { supabaseService } from './SupabaseService.js'; import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js'; import { observabilityService } from './observabilityService.js'; import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js'; import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js'; import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js'; import { loadLatestBotStateSnapshot as loadLatestBotStateSnapshotFromRepository, resolveSnapshotOwnerId as resolveSnapshotOwnerIdFromRepository, saveBotStateSnapshot as saveBotStateSnapshotFromRepository } from './snapshotRepository.js'; import { deleteTradeProfileForUser, ensureDefaultTradeProfileForUser, getCurrentUserProfile, getTradeProfileForUser, listAllTradeProfiles, listActiveTradeProfiles, listTradeProfilesForUser, saveCurrentUserProfile, saveTradeProfileForUser, } from './profileRepository.js'; import { deleteManualEntryForUser, listManualEntriesForUser, saveManualEntryForUser } from './manualEntryRepository.js'; import { listRecentOrders } from './orderActivityRepository.js'; import { createStrategyPreset, listStrategyPresets } from './strategyPresetRepository.js'; import { listRecentTradeHistory, listRecentTradeHistoryKeys } from './tradeHistoryRepository.js'; import * as runtimeOrderRepository from './runtimeOrderRepository.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.js'; import { runBacktest } from '../backtest/index.js'; import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js'; import { canonicalLifecycleService, type CanonicalLifecycleProfileMeta } from './canonicalLifecycleService.js'; import type { TradingFeatureFlagsResponse } from '../../../shared/feature-flags.js'; interface AuthenticatedRequest extends Request { authUserId?: string; authRole?: string; authEmail?: string; authDisplayName?: string; authPlan?: string; requestId?: string; } interface RateLimitBucket { count: number; windowStart: number; } interface RuntimeHealth { tradingLoopRunning: boolean; tradingLoopLastStartedAt: number | null; tradingLoopLastCompletedAt: number | null; tradingLoopLastDurationMs: number; reconciliationRunning: boolean; reconciliationLastRunAt: number | null; reconciliationLastDurationMs: number; staleOrderBacklog: number; parityMismatchCount: number; exchangeConnectivity: 'unknown' | 'healthy' | 'degraded'; reconciliationMismatchCount?: number; reconciliationMissingFromExchange?: number; reconciliationMissingInDb?: number; reconciliationNoGoTrades?: number; reconciliationParityMismatchTrades?: number; reconciliationParityQuarantinedTrades?: number; reconciliationParityAutoClosedTrades?: number; reconciliationParityMaxMismatchNotionalUsd?: number; reconciliationParityTotalMismatchNotionalUsd?: number; reconciliationIntegrityWatchdogTriggered?: boolean; reconciliationFailedProfiles?: number; canonicalLifecycleTruncated?: boolean; canonicalLifecycleOrderRows?: number; } interface TradeAuditEvent { event: string; userId?: string; profileId?: string; symbol?: string; outcome?: 'accepted' | 'rejected' | 'error'; reason?: string; details?: Record; } interface ChatProfileRuleConfig { ruleId: string; enabled: boolean; params: Record; } interface ChatProfilePayload { id?: string; name: string; allocated_capital: number; risk_per_trade_percent: number; symbols: string; is_active: boolean; strategy_config: { rules: ChatProfileRuleConfig[]; riskLimits: { maxDailyLossUsd: number; maxOpenTrades: number; maxConsecutiveLosses: number; }; execution: { orderType: 'market' | 'limit'; cooldownMinutes: number; entryMode: 'both' | 'long_only'; }; }; } interface ChatResponsePayload { action: 'create_profile' | 'update_profile' | 'explain'; profile?: ChatProfilePayload; summary: string; reasoning: string; fallback?: 'local_deterministic'; } interface AccountSnapshot { profileId?: string; userId?: string; buying_power: number; cash: number; currency: string; timestamp: number; } interface OrderFailureRecord { profileId?: string; userId?: string; symbol: string; side: 'BUY' | 'SELL'; qty: number; reason: string; tradeId?: string; subTag?: string; timestamp: number; } export interface BotState { symbols: { [symbol: string]: { price: number; change24h: number; changeToday: number; session: string; volatility: string; signal: string; signalTime?: number; tradingMode?: 'Paper' | 'Live' | 'Alerts'; activePosition?: { side: 'BUY' | 'SELL'; entryPrice: number; size: number; stopLoss: number; takeProfit: number; unrealizedPnl?: number; unrealizedPnlPercent?: number; marketValue?: number; userId?: string; profileId?: string; profileName?: string; tradeId?: string; } | null; priceHistory: Array<{ timestamp: number; price: number }>; rules: { [ruleName: string]: { passed: boolean; reason: string; metadata?: any; }; }; profileSignals?: { [profileId: string]: { profileName?: string; signal: string; passed: boolean; reason?: string; execution?: { status: 'EXECUTED' | 'BLOCKED' | 'SKIPPED'; code: string; reason: string; orderId?: string; }; rules?: { [ruleName: string]: { passed: boolean; reason: string; metadata?: any; }; }; }; }; indicators: { ema20_1h?: number; ema20_15m?: number; ema50_4h?: number; ema200_4h?: number; rsi_1h?: number; rsi_15m?: number; }; }; }; alerts: Array<{ timestamp: number; type: 'signal' | 'pulse' | 'error' | 'info'; symbol: string; message: string; userId?: string; profileId?: string; }>; positions: Array<{ id: string; symbol: string; side: 'BUY' | 'SELL'; size: number; entryPrice: number; currentPrice: number; stopLoss: number; takeProfit: number; unrealizedPnl: number; unrealizedPnlPercent: number; marketValue: number; userId?: string; profileId?: string; profileName?: string; tradeId?: string; }>; orders: Array<{ id: string; symbol: string; type: string; side: string; qty: number; price: number; status: string; timestamp: number; userId?: string; profileId?: string; trade_id?: string; subTag?: string; action?: string; source?: 'BOT' | 'MANUAL'; }>; history: Array<{ symbol: string; side: string; entryPrice: number; exitPrice: number; size: number; pnl: number; pnlPercent: number; reason: string; timestamp: number; userId?: string; profileId?: string; trade_id?: string; source?: 'BOT' | 'MANUAL'; }>; settings: { executionMode: string; riskPerTrade: number; totalCapital: number; maxOpenTrades: number; isAlgoEnabled: boolean; enabledRules: string[]; }; health: HealthSnapshot; uptime: number; accountSnapshot?: AccountSnapshot | null; orderFailures: OrderFailureRecord[]; operationalEvents: OperationalEvent[]; } export class ApiServer { private app = express(); private httpServer = createServer(this.app); private io = new Server(this.httpServer, { cors: { origin: (origin, callback) => { if (this.isCorsOriginAllowed(origin)) { callback(null, true); return; } callback(new Error(`CORS blocked for origin: ${origin || 'unknown'}`)); }, credentials: true } }); private state: BotState = { symbols: {}, positions: [], alerts: [], orders: [], history: [], settings: { executionMode: 'Alerts', riskPerTrade: 0.01, totalCapital: 1000, maxOpenTrades: 3, isAlgoEnabled: false, enabledRules: [] }, health: healthTracker.getSnapshot(), uptime: 0, accountSnapshot: null, orderFailures: [], operationalEvents: [] }; private accountSnapshotCache: AccountSnapshot[] = []; private startTime: number = Date.now(); private storagePath = path.resolve(process.cwd(), 'bot_state.json'); private executionManagers: Map = new Map(); // profileId -> ManualTrader private profileOwners: Map = new Map(); // profileId -> userId private socketsByUser: Map> = new Map(); // userId -> socket ids private aiClient = new AIClient(); private profilePositionsList = new Map(); private profileOrdersList = new Map(); private snapshotOwnerId: string | null = null; private snapshotWriteTimer: NodeJS.Timeout | null = null; private isSnapshotWriteInFlight = false; private snapshotWriteQueued = false; private lastSnapshotWriteAt = 0; private rateLimitBuckets: Map = new Map(); private readonly routeRateLimits = { trade: { limit: 6, windowMs: 60_000 }, close: { limit: 10, windowMs: 60_000 }, chat: { limit: 20, windowMs: 60_000 }, backtest: { limit: 4, windowMs: 60_000 } } as const; private stateWriteTimer: NodeJS.Timeout | null = null; private isStateWriteInFlight = false; private stateWriteQueued = false; private lastHealthBroadcastAt = 0; private readonly healthBroadcastMinIntervalMs = 1000; private canonicalTruncationAlertByScope = new Map(); private runtimeHealth: RuntimeHealth = { tradingLoopRunning: false, tradingLoopLastStartedAt: null, tradingLoopLastCompletedAt: null, tradingLoopLastDurationMs: 0, reconciliationRunning: false, reconciliationLastRunAt: null, reconciliationLastDurationMs: 0, staleOrderBacklog: 0, parityMismatchCount: 0, exchangeConnectivity: 'unknown', canonicalLifecycleTruncated: false, canonicalLifecycleOrderRows: 0 }; constructor(private port: number = 5000) { healthTracker.subscribeTradingControl((update) => { void this.handleTradingControlChanged(update); }); this.loadState(); void this.restoreStateFromLatestSnapshot(); this.setupMiddleware(); this.setupRoutes(); this.setupSocketHandlers(); this.subscribeToOperationalEvents(); this.startServer(); } private subscribeToOperationalEvents() { observabilityService.subscribe((event) => { this.state.operationalEvents = observabilityService.getEvents(); this.broadcastOperationalEvent(event); // Keep operational events durable across restarts via local + DB snapshots. this.saveState(); this.scheduleSnapshotWrite(); }); } private broadcastOperationalEvent(event: OperationalEvent) { this.emitToConnectedUsers('operational_event', (userId, socket) => { if (socket.data.isAdmin) return event; return null; }); } public registerManualTrader(profileId: string, manager: ManualTrader) { this.executionManagers.set(profileId, manager); const owner = String(manager.getUserId() || '').trim(); if (owner) { this.profileOwners.set(profileId, owner); } logger.info(`[API] Registered Manual Trader for profile: ${profileId}`); } public unregisterManualTrader(profileId: string) { if (!profileId) return; this.executionManagers.delete(profileId); this.profileOwners.delete(profileId); this.profilePositionsList.delete(profileId); this.profileOrdersList.delete(profileId); this.state.positions = mergePositionSnapshots(Array.from(this.profilePositionsList.values())); this.state.orders = mergeOrderSnapshots(Array.from(this.profileOrdersList.values())); this.broadcastPositionsUpdate(); this.broadcastOrdersUpdate(); this.saveState(); logger.info(`[API] Unregistered Manual Trader for profile: ${profileId}`); } private getUserRoom(userId: string): string { return `user:${userId}`; } private trackSocket(userId: string, socket: Socket): void { const normalizedUserId = String(userId || '').trim(); if (!normalizedUserId) return; const room = this.getUserRoom(normalizedUserId); socket.join(room); const existing = this.socketsByUser.get(normalizedUserId) || new Set(); existing.add(socket.id); this.socketsByUser.set(normalizedUserId, existing); } private untrackSocket(userId: string, socketId: string): void { const normalizedUserId = String(userId || '').trim(); if (!normalizedUserId) return; const existing = this.socketsByUser.get(normalizedUserId); if (!existing) return; existing.delete(socketId); if (existing.size === 0) { this.socketsByUser.delete(normalizedUserId); return; } this.socketsByUser.set(normalizedUserId, existing); } private resolveProfileOwner(profileId?: string, fallbackUserId?: string): string | null { const fallback = String(fallbackUserId || '').trim(); if (fallback) return fallback; const normalizedProfileId = String(profileId || '').trim(); if (!normalizedProfileId) return null; const mappedOwner = this.profileOwners.get(normalizedProfileId); if (mappedOwner) return mappedOwner; const manager = this.executionManagers.get(normalizedProfileId); if (manager) { const managerOwner = String(manager.getUserId() || '').trim(); if (managerOwner) { this.profileOwners.set(normalizedProfileId, managerOwner); return managerOwner; } } if (normalizedProfileId.startsWith('default-')) { return normalizedProfileId.slice('default-'.length); } if (normalizedProfileId === 'global') { return 'global'; } return null; } private isOwnedByUser(userId: string, recordUserId?: string, profileId?: string): boolean { const normalizedUserId = String(userId || '').trim(); if (!normalizedUserId) return false; const directOwner = String(recordUserId || '').trim(); if (directOwner) return directOwner === normalizedUserId; const profileOwner = this.resolveProfileOwner(profileId); if (!profileOwner) return false; return profileOwner === normalizedUserId; } private getScopedSymbolState(symbolState: BotState['symbols'][string], userId: string): BotState['symbols'][string] { const profileSignals = Object.entries(symbolState.profileSignals || {}).reduce>((acc, [profileId, signal]) => { if (this.isOwnedByUser(userId, undefined, profileId)) { acc[profileId] = signal; } return acc; }, {}); const activePosition = symbolState.activePosition ? (() => { const candidate = symbolState.activePosition as any; if (!this.isOwnedByUser(userId, candidate.userId, candidate.profileId)) { return null; } return symbolState.activePosition; })() : null; return { ...symbolState, activePosition, profileSignals }; } private getScopedState(userId: string, isAdmin: boolean): BotState { const scopedSymbols = Object.entries(this.state.symbols).reduce((acc, [symbol, symbolState]) => { acc[symbol] = this.getScopedSymbolState(symbolState, userId); return acc; }, {}); const scopedPositions = this.state.positions.filter((position) => this.isOwnedByUser(userId, position.userId, position.profileId) ); const scopedOrders = this.state.orders.filter((order) => this.isOwnedByUser(userId, order.userId, order.profileId) ); const scopedHistory = this.state.history.filter((trade) => this.isOwnedByUser(userId, trade.userId, trade.profileId) ); const scopedAlerts = this.state.alerts.filter((alert) => { const directUser = String(alert.userId || '').trim(); if (directUser) return directUser === userId; const profileOwner = this.resolveProfileOwner(alert.profileId); if (profileOwner) return profileOwner === userId; return true; }); const scopedOrderFailures = this.state.orderFailures.filter((failure) => this.isOwnedByUser(userId, failure.userId, failure.profileId) ); const latestAssociatedSnapshot = [...this.accountSnapshotCache] .reverse() .find((snapshot) => this.isOwnedByUser(userId, snapshot.userId, snapshot.profileId)); const fallbackSnapshot = this.state.accountSnapshot; const scopedAccountSnapshot = latestAssociatedSnapshot || (fallbackSnapshot && (!fallbackSnapshot.profileId || this.isOwnedByUser(userId, fallbackSnapshot.userId, fallbackSnapshot.profileId)) ? fallbackSnapshot : null); return { ...this.state, symbols: scopedSymbols, positions: scopedPositions, orders: scopedOrders, history: scopedHistory, alerts: scopedAlerts, health: healthTracker.getSnapshot(), accountSnapshot: scopedAccountSnapshot, orderFailures: scopedOrderFailures, operationalEvents: isAdmin ? this.state.operationalEvents : [] }; } private emitToUser(userId: string, event: string, payload: unknown): void { const normalizedUserId = String(userId || '').trim(); if (!normalizedUserId) return; this.io.to(this.getUserRoom(normalizedUserId)).emit(event, payload); } private emitToConnectedUsers(event: string, payloadBuilder: (userId: string, socket: Socket) => T): void { for (const [socketId, socket] of this.io.sockets.sockets) { const userId = socket.data.userId; if (!userId) continue; const payload = payloadBuilder(userId, socket); socket.emit(event, payload); } } private broadcastPositionsUpdate(): void { this.emitToConnectedUsers('positions_update', (userId) => this.state.positions.filter((position) => this.isOwnedByUser(userId, position.userId, position.profileId)) ); } private broadcastOrdersUpdate(): void { this.emitToConnectedUsers('orders_update', (userId) => this.state.orders.filter((order) => this.isOwnedByUser(userId, order.userId, order.profileId)) ); } private broadcastHistoryUpdate(trade: BotState['history'][0]): void { const owner = this.resolveProfileOwner(trade.profileId, trade.userId); if (!owner) return; this.emitToUser(owner, 'history_update', trade); } private broadcastSymbolUpdate(symbol: string): void { const symbolState = this.state.symbols[symbol]; if (!symbolState) return; this.emitToConnectedUsers('symbol_update', (userId) => ({ symbol, data: this.getScopedSymbolState(symbolState, userId) })); } private broadcastSettingsUpdate(): void { this.emitToConnectedUsers('settings_update', () => this.state.settings); } private broadcastHealthUpdate(): void { this.publishHealthSnapshot({ broadcast: true, force: true }); } private isCorsOriginAllowed(origin?: string): boolean { if (!origin) return true; return config.ALLOWED_ORIGINS.includes(origin); } private extractBearerToken(authorizationHeader?: string | string[]): string | null { if (!authorizationHeader || typeof authorizationHeader !== 'string') return null; const [scheme, token] = authorizationHeader.split(' '); if (scheme?.toLowerCase() !== 'bearer' || !token) return null; return token.trim(); } private async handleTradingControlChanged(update: TradingControlSnapshot): Promise { this.state.health = { ...this.state.health, tradingControl: update, }; this.broadcastHealthUpdate(); this.saveState(); this.scheduleSnapshotWrite(); await saveGlobalTradingControl(update); } private requireAuth = async (req: Request, res: Response, next: NextFunction): Promise => { const token = this.extractBearerToken(req.headers.authorization); if (!token) { res.status(401).json({ error: 'Unauthorized: missing bearer token' }); return; } const verified = await verifyTradingAccessToken(token); if (!verified.userId) { res.status(401).json({ error: `Unauthorized: ${verified.error || 'invalid token'}` }); return; } (req as AuthenticatedRequest).authUserId = verified.userId; (req as AuthenticatedRequest).authRole = verified.role; (req as AuthenticatedRequest).authEmail = verified.email; (req as AuthenticatedRequest).authDisplayName = verified.displayName; (req as AuthenticatedRequest).authPlan = verified.plan; next(); }; private requireAdmin = async (req: Request, res: Response, next: NextFunction): Promise => { const userId = (req as AuthenticatedRequest).authUserId; if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const isAdmin = await isTradingAdmin(userId, (req as AuthenticatedRequest).authRole); if (!isAdmin) { res.status(403).json({ error: 'Forbidden: Admin role required' }); return; } next(); } catch (err: any) { res.status(500).json({ error: `Internal server error: ${err.message}` }); } }; private checkRateLimit(userId: string, route: keyof ApiServer['routeRateLimits']): { allowed: boolean; retryAfterMs: number } { const { limit, windowMs } = this.routeRateLimits[route]; const bucketKey = `${route}:${userId}`; const now = Date.now(); const existing = this.rateLimitBuckets.get(bucketKey); if (!existing || (now - existing.windowStart) >= windowMs) { this.rateLimitBuckets.set(bucketKey, { count: 1, windowStart: now }); return { allowed: true, retryAfterMs: 0 }; } if (existing.count >= limit) { return { allowed: false, retryAfterMs: Math.max(0, windowMs - (now - existing.windowStart)) }; } existing.count += 1; this.rateLimitBuckets.set(bucketKey, existing); return { allowed: true, retryAfterMs: 0 }; } private enforceRateLimit(req: AuthenticatedRequest, res: Response, route: keyof ApiServer['routeRateLimits']): boolean { const userId = req.authUserId; if (!userId) { res.status(401).json({ success: false, error: 'Unauthorized' }); return false; } const { allowed, retryAfterMs } = this.checkRateLimit(userId, route); if (!allowed) { res.status(429).json({ success: false, error: 'Rate limit exceeded', retryAfterMs }); return false; } return true; } private getPersistableState() { return { symbols: this.state.symbols, positions: this.state.positions, alerts: this.state.alerts, orders: this.state.orders, history: this.state.history, settings: this.state.settings, health: { tradingControl: this.state.health.tradingControl }, operationalEvents: this.state.operationalEvents }; } private getHealthStatus(now: number = Date.now()): 'healthy' | 'degraded' { const lastLoopCompletedAt = this.runtimeHealth.tradingLoopLastCompletedAt; const maxLoopGapMs = Math.max(config.POLLING_INTERVAL * 2, 120_000); if (!lastLoopCompletedAt) return 'degraded'; if ((now - lastLoopCompletedAt) > maxLoopGapMs) return 'degraded'; if (this.runtimeHealth.exchangeConnectivity === 'degraded') return 'degraded'; return 'healthy'; } private async flushStateToDisk(): Promise { if (this.isStateWriteInFlight) { this.stateWriteQueued = true; return; } this.isStateWriteInFlight = true; const stateToSave = this.getPersistableState(); const serializedState = JSON.stringify(stateToSave, null, 2); const tmpPath = `${this.storagePath}.tmp`; const backupPath = `${this.storagePath}.bak`; try { await fs.promises.writeFile(tmpPath, serializedState, 'utf8'); // Validate serialized snapshot before promoting the file. JSON.parse(await fs.promises.readFile(tmpPath, 'utf8')); if (fs.existsSync(this.storagePath)) { await fs.promises.copyFile(this.storagePath, backupPath); } try { await fs.promises.rename(tmpPath, this.storagePath); } catch { // Windows fallback when rename replacement is blocked. await fs.promises.copyFile(tmpPath, this.storagePath); await fs.promises.unlink(tmpPath).catch(() => undefined); } } catch (error) { logger.error('[API] Failed to save state:', error); } finally { this.isStateWriteInFlight = false; if (this.stateWriteQueued) { this.stateWriteQueued = false; void this.flushStateToDisk(); } } } private scheduleStateWrite(): void { if (this.stateWriteTimer) { clearTimeout(this.stateWriteTimer); } this.stateWriteTimer = setTimeout(() => { this.stateWriteTimer = null; void this.flushStateToDisk(); }, 200); } private auditTradeEvent(evt: TradeAuditEvent): void { const payload = { ts: new Date().toISOString(), ...evt }; logger.info(`[AUDIT] ${JSON.stringify(payload)}`); } private buildLocalChatFallback(message: string, context: any[]): ChatResponsePayload { const lower = String(message || '').toLowerCase(); const asksForExplain = /(what|how|why|help|explain|suggest)/i.test(lower) && !/(create|build|make|generate|new profile|strategy|setup|configure|update|modify)/i.test(lower); if (asksForExplain) { return { action: 'explain', summary: 'AI provider is currently unavailable. A local fallback can still generate deterministic profile configurations.', reasoning: 'Use prompts that include risk appetite, symbols, capital, and whether you want long-only or both sides.' }; } const symbols = this.extractSymbolsFromMessage(message); const entryMode: 'both' | 'long_only' = /(long[\s_-]*only|buy[\s_-]*only)/i.test(lower) ? 'long_only' : 'both'; const orderType: 'market' | 'limit' = /\blimit\b/i.test(lower) ? 'limit' : 'market'; const cooldownMinutes = this.extractCooldownMinutes(message); const allocatedCapital = this.extractCapital(message); const { riskPerTradePercent, riskTier } = this.extractRiskProfile(message); const aiRuleEnabled = /\bai\b|\bsentiment\b|\bllm\b/i.test(lower); const sessions = this.extractSessions(message); const profileName = this.buildFallbackProfileName(lower); const isActive = !/\b(inactive|paused|pause|draft)\b/i.test(lower); const riskLimits = { maxDailyLossUsd: riskTier === 'aggressive' ? Math.max(100, Math.round(allocatedCapital * 0.1)) : riskTier === 'conservative' ? Math.max(25, Math.round(allocatedCapital * 0.03)) : Math.max(50, Math.round(allocatedCapital * 0.05)), maxOpenTrades: riskTier === 'aggressive' ? 5 : riskTier === 'conservative' ? 2 : 3, maxConsecutiveLosses: riskTier === 'aggressive' ? 3 : 2 }; const profile: ChatProfilePayload = { name: profileName, allocated_capital: allocatedCapital, risk_per_trade_percent: riskPerTradePercent, symbols, is_active: isActive, strategy_config: { rules: [ { ruleId: 'TrendBiasRule', enabled: true, params: { fastPeriod: 50, slowPeriod: 200 } }, { ruleId: 'MomentumRule', enabled: true, params: { rsiPeriod: 14, overbought: 70, oversold: 30 } }, { ruleId: 'ZoneRule', enabled: true, params: { zonePercent: 1.5 } }, { ruleId: 'SessionRule', enabled: true, params: { sessions } }, { ruleId: 'EntryTriggerRule', enabled: true, params: { showPatterns: true } }, { ruleId: 'RiskManagementRule', enabled: true, params: { maxRisk: 2.0 } }, { ruleId: 'AIAnalysisRule', enabled: aiRuleEnabled, params: { minConfidence: 70 } } ], riskLimits, execution: { orderType, cooldownMinutes, entryMode } } }; const updateTarget = this.detectProfileToUpdate(message, context); if (updateTarget) { const existingConfig = updateTarget.strategy_config || {}; const existingExecution = existingConfig.execution || {}; const existingRiskLimits = existingConfig.riskLimits || {}; const existingRules = Array.isArray(existingConfig.rules) ? existingConfig.rules : profile.strategy_config.rules; const mergedProfile: ChatProfilePayload = { ...updateTarget, id: updateTarget.id, name: updateTarget.name || profile.name, allocated_capital: allocatedCapital || Number(updateTarget.allocated_capital || profile.allocated_capital), risk_per_trade_percent: riskPerTradePercent || Number(updateTarget.risk_per_trade_percent || profile.risk_per_trade_percent), symbols: symbols || updateTarget.symbols || profile.symbols, is_active: typeof updateTarget.is_active === 'boolean' ? updateTarget.is_active : isActive, strategy_config: { ...existingConfig, rules: existingRules, riskLimits: { maxDailyLossUsd: Number(existingRiskLimits.maxDailyLossUsd || riskLimits.maxDailyLossUsd), maxOpenTrades: Number(existingRiskLimits.maxOpenTrades || riskLimits.maxOpenTrades), maxConsecutiveLosses: Number(existingRiskLimits.maxConsecutiveLosses ?? riskLimits.maxConsecutiveLosses) }, execution: { orderType: existingExecution.orderType === 'limit' ? 'limit' : orderType, cooldownMinutes: Number(existingExecution.cooldownMinutes ?? cooldownMinutes), entryMode: existingExecution.entryMode === 'long_only' ? 'long_only' : entryMode } } }; return { action: 'update_profile', profile: mergedProfile, summary: `AI was unavailable. Built a deterministic fallback update for profile "${mergedProfile.name}".`, reasoning: `Applied local heuristics from your prompt (symbols=${symbols}, risk=${riskPerTradePercent}%, mode=${entryMode}, orderType=${orderType}).`, fallback: 'local_deterministic' }; } return { action: 'create_profile', profile, summary: `AI was unavailable. Built a deterministic fallback profile "${profileName}".`, reasoning: `Applied local heuristics from your prompt (symbols=${symbols}, risk=${riskPerTradePercent}%, mode=${entryMode}, orderType=${orderType}, cooldown=${cooldownMinutes}m).`, fallback: 'local_deterministic' }; } private extractCapital(message: string): number { const normalized = String(message || ''); const directPattern = /\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*([kKmM]?)/; const keywordPattern = /\b(capital|budget|allocate|allocated)\b[^0-9$]*\$?\s*([0-9][0-9,]*(?:\.[0-9]+)?)\s*([kKmM]?)/i; let numericPart = ''; let suffix = ''; const keywordMatch = normalized.match(keywordPattern); if (keywordMatch) { numericPart = keywordMatch[2]; suffix = keywordMatch[3] || ''; } else { const directMatch = normalized.match(directPattern); if (directMatch) { numericPart = directMatch[1]; suffix = directMatch[2] || ''; } } const base = Number(String(numericPart || '1000').replace(/,/g, '')); if (!Number.isFinite(base) || base <= 0) return 1000; const normalizedSuffix = String(suffix || '').toLowerCase(); if (normalizedSuffix === 'k') return Math.round(base * 1000); if (normalizedSuffix === 'm') return Math.round(base * 1000000); return Math.round(base); } private extractCooldownMinutes(message: string): number { const normalized = String(message || ''); const explicit = normalized.match(/([0-9]{1,3})\s*(?:min|mins|minute|minutes|m)\s*(?:cooldown|delay|wait)?/i); if (explicit) { const parsed = Number(explicit[1]); if (Number.isFinite(parsed) && parsed >= 1) return Math.min(240, Math.max(1, parsed)); } if (/\bscalp|scalper\b/i.test(normalized)) return 10; if (/\bswing\b/i.test(normalized)) return 45; return 30; } private extractRiskProfile(message: string): { riskPerTradePercent: number; riskTier: 'conservative' | 'balanced' | 'aggressive' } { const normalized = String(message || '').toLowerCase(); const explicit = normalized.match(/([0-9]+(?:\.[0-9]+)?)\s*%\s*(?:risk|risk\s*per\s*trade)/i); if (explicit) { const parsed = Number(explicit[1]); if (Number.isFinite(parsed) && parsed > 0) { const bounded = Math.min(10, Math.max(0.1, parsed)); const riskTier = bounded <= 1 ? 'conservative' : bounded >= 2.5 ? 'aggressive' : 'balanced'; return { riskPerTradePercent: Number(bounded.toFixed(2)), riskTier }; } } if (/\b(conservative|low[\s-]*risk|safe)\b/i.test(normalized)) { return { riskPerTradePercent: 0.8, riskTier: 'conservative' }; } if (/\b(aggressive|high[\s-]*risk|scalp|scalper)\b/i.test(normalized)) { return { riskPerTradePercent: 2.5, riskTier: 'aggressive' }; } return { riskPerTradePercent: 1.2, riskTier: 'balanced' }; } private extractSymbolsFromMessage(message: string): string { const upper = String(message || '').toUpperCase(); const symbols = new Set(); const explicitPairs = upper.match(/\b[A-Z]{2,10}\/[A-Z]{2,10}\b/g) || []; explicitPairs.forEach((pair) => symbols.add(pair)); const knownAssets = upper.match(/\b(BTC|ETH|SOL|DOGE|XRP|ADA|BNB|AVAX|MATIC|LTC|LINK|DOT|TRX|SHIB)\b/g) || []; for (const asset of knownAssets) { symbols.add(`${asset}/USDT`); } if (symbols.size === 0) { return 'BTC/USDT, ETH/USDT'; } return Array.from(symbols).slice(0, 6).join(', '); } private extractSessions(message: string): string { const lower = String(message || '').toLowerCase(); const sessions: string[] = []; if (/\blondon\b|\bldn\b/.test(lower)) sessions.push('London'); if (/\bnew york\b|\bny\b/.test(lower)) sessions.push('NY'); if (/\btokyo\b|\btok\b/.test(lower)) sessions.push('Tokyo'); if (/\bsydney\b|\bsyd\b/.test(lower)) sessions.push('Sydney'); return sessions.length > 0 ? sessions.join(',') : 'London,NY'; } private buildFallbackProfileName(messageLower: string): string { if (/\bconservative\b/.test(messageLower)) return 'Conservative Fallback Strategy'; if (/\baggressive|scalp|scalper\b/.test(messageLower)) return 'Aggressive Fallback Strategy'; if (/\bswing\b/.test(messageLower)) return 'Swing Fallback Strategy'; return 'AI Fallback Strategy'; } private detectProfileToUpdate(message: string, context: any[]): any | null { const lower = String(message || '').toLowerCase(); if (!/\b(update|modify|change|edit|tweak)\b/.test(lower)) return null; if (!Array.isArray(context) || context.length === 0) return null; for (const profile of context) { const name = String(profile?.name || '').toLowerCase().trim(); if (!name) continue; if (lower.includes(name)) return profile; } return null; } private normalizeBacktestTimeframe(value: unknown): BacktestTimeframe { const normalized = String(value || '').trim().toLowerCase(); if (normalized === '1m' || normalized === '1min') return '1m'; if (normalized === '15m' || normalized === '15min') return '15m'; if (normalized === '1h' || normalized === '60m') return '1h'; if (normalized === '4h' || normalized === '240m') return '4h'; throw new Error(`Invalid timeframe "${String(value || '')}". Use 1m, 15m, 1h, or 4h.`); } private parseSymbolList(input: unknown): string[] { if (Array.isArray(input)) { return input .map((value) => String(value || '').trim().toUpperCase()) .filter(Boolean); } return String(input || '') .split(',') .map((value) => value.trim().toUpperCase()) .filter(Boolean); } private enforceBacktestPayloadGuards(dataSource: any): void { if (!dataSource || typeof dataSource !== 'object') { throw new Error('Backtest request requires dataSource.'); } if (dataSource.type === 'csv') { const payload = String(dataSource.payload || ''); const bytes = Buffer.byteLength(payload, 'utf8'); if (bytes > config.BACKTEST_MAX_CSV_BYTES) { throw new Error(`CSV payload too large (${bytes} bytes > ${config.BACKTEST_MAX_CSV_BYTES}).`); } const rows = payload.split(/\r?\n/).filter((line: string) => line.trim().length > 0).length; if (rows > config.BACKTEST_MAX_ROWS + 1) { throw new Error(`CSV row count exceeds BACKTEST_MAX_ROWS (${config.BACKTEST_MAX_ROWS}).`); } return; } if (dataSource.type === 'json' || dataSource.type === 'replay') { const candles = dataSource?.payload?.candles; if (Array.isArray(candles) && candles.length > config.BACKTEST_MAX_ROWS) { throw new Error(`JSON candle count exceeds BACKTEST_MAX_ROWS (${config.BACKTEST_MAX_ROWS}).`); } return; } if (dataSource.type === 'kraken') { return; } throw new Error(`Unsupported backtest dataSource.type "${String(dataSource.type || '')}".`); } private loadState() { const candidatePaths = [ this.storagePath, `${this.storagePath}.bak`, `${this.storagePath}.tmp` ]; for (const candidate of candidatePaths) { try { if (!fs.existsSync(candidate)) continue; const data = fs.readFileSync(candidate, 'utf8'); const savedState = JSON.parse(data); if (!savedState) continue; this.state = { ...this.state, ...savedState, settings: savedState.settings || this.state.settings, health: { ...this.state.health, ...(savedState.health || {}) } }; if (this.state.health.tradingControl) { healthTracker.recordTradingControl(this.state.health.tradingControl); } logger.info(`[API] Restored state from ${candidate}`); return; } catch (error) { logger.error(`[API] Failed to load state from ${candidate}:`, error); } } } private async resolveSnapshotOwnerId(): Promise { if (this.snapshotOwnerId) return this.snapshotOwnerId; const owner = await resolveSnapshotOwnerIdFromRepository(supabaseService); this.snapshotOwnerId = owner; return owner; } private async restoreStateFromLatestSnapshot(): Promise { try { const ownerId = await this.resolveSnapshotOwnerId(); if (!ownerId) { logger.warn('[API] Snapshot owner not resolved; skipping snapshot restore.'); } else { const snapshot = await loadLatestBotStateSnapshotFromRepository(ownerId, supabaseService); if (snapshot && snapshot.state) { const restoredState = snapshot.state as Partial; this.state = { ...this.state, ...restoredState, settings: restoredState.settings || this.state.settings, health: { ...this.state.health, ...(restoredState.health || {}) } }; if (this.state.health.tradingControl) { healthTracker.recordTradingControl(this.state.health.tradingControl); } logger.info(`[API] Restored runtime state from snapshot repository (user=${ownerId}).`); } } const cosmosTradingControl = await loadGlobalTradingControl(); if (cosmosTradingControl) { healthTracker.recordTradingControl(cosmosTradingControl); logger.info('[API] Restored trading control from Cosmos.'); } } catch (error: any) { logger.error('[API] Failed to restore state from Supabase snapshot:', error); } } private scheduleSnapshotWrite(): void { if (!config.ENABLE_DB_SNAPSHOTS) return; if (this.snapshotWriteTimer) { clearTimeout(this.snapshotWriteTimer); } // Small debounce to catch bursts, but the actual write is throttled in persistSnapshotToDb this.snapshotWriteTimer = setTimeout(() => { void this.persistSnapshotToDb(); }, 5000); } private async persistSnapshotToDb(): Promise { if (!config.ENABLE_DB_SNAPSHOTS) return; const now = Date.now(); const elapsed = now - this.lastSnapshotWriteAt; if (elapsed < config.DB_SNAPSHOT_INTERVAL_MS) { // If we are too soon, schedule another check at the next interval boundary const remaining = config.DB_SNAPSHOT_INTERVAL_MS - elapsed; if (this.snapshotWriteTimer) clearTimeout(this.snapshotWriteTimer); this.snapshotWriteTimer = setTimeout(() => { void this.persistSnapshotToDb(); }, Math.max(remaining, 5000)); return; } if (this.isSnapshotWriteInFlight) { this.snapshotWriteQueued = true; return; } this.isSnapshotWriteInFlight = true; try { const ownerId = await this.resolveSnapshotOwnerId(); if (!ownerId) return; await saveBotStateSnapshotFromRepository(ownerId, this.getPersistableState(), supabaseService); this.lastSnapshotWriteAt = Date.now(); logger.info(`[API] Persisted snapshot for ${ownerId}. Interval: ${Math.round(elapsed / 1000)}s`); } catch (error: any) { logger.error(`[API] Snapshot persistence failed: ${error.message}`); } finally { this.isSnapshotWriteInFlight = false; if (this.snapshotWriteQueued) { this.snapshotWriteQueued = false; this.scheduleSnapshotWrite(); } } } private saveState() { this.scheduleStateWrite(); this.scheduleSnapshotWrite(); } private setupMiddleware() { this.app.use(cors({ origin: (origin, callback) => { if (this.isCorsOriginAllowed(origin)) { callback(null, true); return; } callback(new Error(`CORS blocked for origin: ${origin || 'unknown'}`)); }, credentials: true })); this.app.use((req: AuthenticatedRequest, res: Response, next: NextFunction) => { const inbound = String(req.headers['x-request-id'] || '').trim(); const requestId = inbound || `backend-${randomUUID()}`; req.requestId = requestId; res.setHeader('x-request-id', requestId); next(); }); this.app.use(express.json()); } private setupRoutes() { this.app.get('/health', (req, res) => { const now = Date.now(); const status = this.getHealthStatus(now); res.status(status === 'healthy' ? 200 : 503).json({ status, uptime: now - this.startTime, runtime: this.runtimeHealth }); }); this.app.get('/health/live', (req, res) => { res.status(200).json({ status: 'alive', uptime: Date.now() - this.startTime }); }); this.app.get('/health/ready', (req, res) => { const status = this.getHealthStatus(); res.status(status === 'healthy' ? 200 : 503).json({ status, runtime: this.runtimeHealth }); }); this.app.get('/internal/health', (req, res) => { const snapshot = healthTracker.getSnapshot(); res.status(200).json({ ...snapshot, observability: observabilityService.getSummary(), sloFlags: observabilityService.sloFlags() }); }); this.app.get('/metrics', async (req, res) => { res.set('Content-Type', observabilityService.contentType()); try { const metrics = await observabilityService.metrics(); res.send(metrics); } catch (err: any) { logger.error(`[Metrics] Failed to render Prometheus metrics: ${err.message}`); res.status(500).json({ error: 'Metrics render failed' }); } }); this.app.get('/api/state', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } this.state.uptime = Date.now() - this.startTime; const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const scopedState = this.getScopedState(authUserId, isAdmin); res.json({ ...scopedState, runtimeHealth: this.runtimeHealth }); }); this.app.get('/api/lifecycle/canonical', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const requestedProfileId = String(req.query.profileId || '').trim(); const splitByProfileQuery = String(req.query.splitByProfile || '').trim().toLowerCase(); const splitByProfile = splitByProfileQuery ? splitByProfileQuery !== 'false' : true; const canonicalMaxRowsCap = 200_000; const defaultMaxRows = Math.max(1_000, Math.min(canonicalMaxRowsCap, Number(config.CANONICAL_LIFECYCLE_MAX_ROWS || 200_000))); const maxRows = Math.max( 1_000, Math.min(canonicalMaxRowsCap, Number.parseInt(String(req.query.maxRows || defaultMaxRows), 10) || defaultMaxRows) ); const profileRows = isAdmin ? await listAllTradeProfiles(supabaseService) : await listTradeProfilesForUser(authUserId, supabaseService); if (requestedProfileId && !profileRows.some((row) => row.id === requestedProfileId)) { res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to scoped user context' }); return; } const profilesInScope = requestedProfileId ? profileRows.filter((row) => row.id === requestedProfileId) : profileRows; const profileMeta: CanonicalLifecycleProfileMeta[] = profilesInScope.map((row) => ({ id: String(row.id), userId: String(row.user_id), name: String(row.name || row.id), allocatedCapital: Number(row.allocated_capital || 0), isActive: Boolean(row.is_active) })); const useProfileScopedFetch = !requestedProfileId && splitByProfile && profilesInScope.length > 1; let lifecycleOrderRows: any[] = []; let lifecycleOrderTruncated = false; if (useProfileScopedFetch) { const deduped = new Map(); for (const profileRow of profilesInScope) { const profileScoped = isAdmin ? await runtimeOrderRepository.getFilledLifecycleOrdersGlobal({ profileId: String(profileRow.id), maxRows }) : await runtimeOrderRepository.getFilledLifecycleOrdersForUser({ userId: authUserId, profileId: String(profileRow.id), maxRows }); lifecycleOrderTruncated = lifecycleOrderTruncated || profileScoped.truncated; for (const row of profileScoped.rows || []) { const key = String( row?.id || row?.order_id || `${row?.profile_id || profileRow.id}|${row?.trade_id || ''}|${row?.created_at || row?.timestamp || ''}|${row?.side || ''}|${row?.action || ''}` ).trim(); if (!key) continue; if (!deduped.has(key)) { deduped.set(key, row); } } } lifecycleOrderRows = Array.from(deduped.values()).sort((a: any, b: any) => { const aTs = Date.parse(String(a?.created_at || a?.filled_at || a?.timestamp || 0)); const bTs = Date.parse(String(b?.created_at || b?.filled_at || b?.timestamp || 0)); if (Number.isFinite(aTs) && Number.isFinite(bTs) && aTs !== bTs) { return aTs - bTs; } const aId = String(a?.id || a?.order_id || ''); const bId = String(b?.id || b?.order_id || ''); return aId.localeCompare(bId); }); } else { const lifecycleOrderResult = isAdmin ? await runtimeOrderRepository.getFilledLifecycleOrdersGlobal({ profileId: requestedProfileId || undefined, maxRows }) : await runtimeOrderRepository.getFilledLifecycleOrdersForUser({ userId: authUserId, profileId: requestedProfileId || undefined, maxRows }); lifecycleOrderRows = lifecycleOrderResult.rows || []; lifecycleOrderTruncated = Boolean(lifecycleOrderResult.truncated); } const symbolPrices = Object.entries(this.state.symbols || {}).reduce>((acc, [symbol, value]) => { const price = Number(value?.price || 0); if (Number.isFinite(price) && price > 0) { acc[symbol] = price; } return acc; }, {}); const snapshot = canonicalLifecycleService.buildSnapshot({ orders: lifecycleOrderRows, profiles: profileMeta, symbolPrices, truncated: lifecycleOrderTruncated }); this.updateRuntimeHealth({ canonicalLifecycleTruncated: Boolean(snapshot.diagnostics.truncated), canonicalLifecycleOrderRows: Number(snapshot.diagnostics.orderRows || 0) }); if (snapshot.diagnostics.truncated) { const alertScope = requestedProfileId || (isAdmin ? 'global-admin' : authUserId); const now = Date.now(); const throttleMs = Math.max(0, Number(config.CANONICAL_LIFECYCLE_TRUNCATION_ALERT_MS || 600_000)); const lastAlertAt = this.canonicalTruncationAlertByScope.get(alertScope) || 0; if (throttleMs <= 0 || (now - lastAlertAt) >= throttleMs) { this.canonicalTruncationAlertByScope.set(alertScope, now); observabilityService.emitEvent({ type: 'RECONCILIATION_DEGRADED', severity: 'WARN', message: `Canonical lifecycle response truncated at ${snapshot.diagnostics.orderRows} rows (maxRows=${maxRows}). Increase CANONICAL_LIFECYCLE_MAX_ROWS or query narrower scope.`, profileId: requestedProfileId || undefined, userId: isAdmin ? undefined : authUserId }); } } res.json({ success: true, scope: { isAdmin, profileId: requestedProfileId || null, profileCount: profileMeta.length, splitByProfile: useProfileScopedFetch }, snapshot }); } catch (error: any) { res.status(500).json({ success: false, error: error.message }); } }); this.app.get('/api/alerts', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } const limit = parseInt(req.query.limit as string) || 50; const isAdmin = await supabaseService.isAdmin(authUserId); const scopedState = this.getScopedState(authUserId, isAdmin); const alerts = scopedState.alerts; res.json(alerts.slice(-limit)); }); // --- SAFE ADMIN TRADE CONTROL ENDPOINTS --- this.app.get('/internal/trading/status', this.requireAuth, (req, res) => { res.json(healthTracker.getSnapshot().tradingControl); }); this.app.post('/internal/trading/pause', this.requireAuth, this.requireAdmin, (req, res) => { const { reason } = req.body; const userId = (req as AuthenticatedRequest).authUserId || 'unknown'; const update: TradingControlSnapshot = { mode: 'PAUSED', lastChangedBy: userId, lastChangedAt: Date.now(), reason: reason || 'Manual admin pause' }; healthTracker.recordTradingControl(update); observabilityService.emitEvent({ type: 'SYSTEM_ERROR', severity: 'WARN', message: `Trading PAUSED by operator: ${update.reason}`, userId }); logger.warn(`[Admin] Trading PAUSED by ${userId}. Reason: ${update.reason}`); res.json({ success: true, status: update }); }); this.app.post('/internal/trading/resume', this.requireAuth, this.requireAdmin, (req, res) => { const { reason } = req.body; const userId = (req as AuthenticatedRequest).authUserId || 'unknown'; const update: TradingControlSnapshot = { mode: 'RUNNING', lastChangedBy: userId, lastChangedAt: Date.now(), reason: reason || 'Manual admin resume' }; healthTracker.recordTradingControl(update); observabilityService.emitEvent({ type: 'SYSTEM_ERROR', severity: 'INFO', message: `Trading RESUMED by operator: ${update.reason}`, userId }); logger.info(`[Admin] Trading RESUMED by ${userId}.`); res.json({ success: true, status: update }); }); // Non-destructive batch rollback for reconciliation backfill rows. this.app.post('/api/admin/revert-backfill-batch', this.requireAuth, this.requireAdmin, async (req, res) => { const batchId = String(req.body.batchId || '').trim(); if (!batchId) { res.status(400).json({ success: false, error: 'batchId is required' }); return; } try { const result = await runtimeOrderRepository.revertBackfillBatch(batchId); if (result.errors.length > 0) { res.status(500).json({ success: false, reverted: result.reverted, errors: result.errors }); return; } res.json({ success: true, reverted: result.reverted }); } catch (err: any) { res.status(500).json({ success: false, error: err.message }); } }); this.app.get('/api/symbol/:symbol', this.requireAuth, (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } const symbolParam = req.params.symbol; const symbol = Array.isArray(symbolParam) ? symbolParam[0] : symbolParam; if (!symbol) { res.status(400).json({ error: 'Symbol is required' }); return; } const symbolState = this.state.symbols[symbol]; if (symbolState) { res.json(this.getScopedSymbolState(symbolState, authUserId)); } else { res.status(404).json({ error: 'Symbol not found' }); } }); // --- Bot Configuration (non-secret) --- this.app.get('/api/config', this.requireAuth, (req, res) => { res.json({ DATA_PROVIDER: config.DATA_PROVIDER, EXECUTION_PROVIDER: config.EXECUTION_PROVIDER, SYMBOLS: config.SYMBOLS, TIMEFRAME: config.TIMEFRAME, POLLING_INTERVAL: config.POLLING_INTERVAL, PAPER_TRADING: config.PAPER_TRADING, ASSET_CLASS: config.ASSET_CLASS, EXCHANGE: config.EXCHANGE, ENABLE_TRADING: config.ENABLE_TRADING, TOTAL_CAPITAL: config.TOTAL_CAPITAL, MAX_OPEN_TRADES: config.MAX_OPEN_TRADES, COOLDOWN_MS: config.COOLDOWN_MS, PROFIT_EXIT_PERCENT: config.PROFIT_EXIT_PERCENT, TRAILING_STOP_PERCENT: config.TRAILING_STOP_PERCENT, ENABLE_STRICT_CAPITAL_GUARD: config.ENABLE_STRICT_CAPITAL_GUARD, ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH: config.ENABLE_AUTO_PAUSE_ON_SLIPPAGE_BREACH, STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT: config.STRICT_CAPITAL_SLIPPAGE_BUFFER_PCT, STRICT_CAPITAL_FEE_BUFFER_PCT: config.STRICT_CAPITAL_FEE_BUFFER_PCT, STRICT_CAPITAL_MIN_RESERVE_USD: config.STRICT_CAPITAL_MIN_RESERVE_USD, PROFILE_SYNC_INTERVAL_MS: config.PROFILE_SYNC_INTERVAL_MS, MONITOR_INTERVAL_MS: config.MONITOR_INTERVAL_MS, STALE_ORDER_THRESHOLD_MINUTES: config.STALE_ORDER_THRESHOLD_MINUTES, ORDER_SYNC_MISSING_GRACE_MINUTES: config.ORDER_SYNC_MISSING_GRACE_MINUTES, ORDER_SYNC_MISSING_CONFIRMATION_COUNT: config.ORDER_SYNC_MISSING_CONFIRMATION_COUNT, DYNAMIC_CONFIG_REFRESH_MS: config.DYNAMIC_CONFIG_REFRESH_MS, LOW_STRESS_MODE: config.LOW_STRESS_MODE, ENABLE_RECON_EXIT_BACKFILL: config.ENABLE_RECON_EXIT_BACKFILL, RECON_EXIT_BACKFILL_DRY_RUN: config.RECON_EXIT_BACKFILL_DRY_RUN, RECON_EXIT_BACKFILL_REQUIRE_PAUSE: config.RECON_EXIT_BACKFILL_REQUIRE_PAUSE, RECON_EXIT_BACKFILL_DUST_ABS_QTY: config.RECON_EXIT_BACKFILL_DUST_ABS_QTY, RECON_EXIT_BACKFILL_DUST_REL_PCT: config.RECON_EXIT_BACKFILL_DUST_REL_PCT, RECON_EXIT_BACKFILL_LOOKBACK_HOURS: config.RECON_EXIT_BACKFILL_LOOKBACK_HOURS, RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION: config.RECON_EXIT_BACKFILL_REQUIRE_STRONG_ATTRIBUTION, RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH: config.RECON_EXIT_BACKFILL_ALLOW_HEURISTIC_MATCH, RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES: config.RECON_EXIT_BACKFILL_FILL_AFTER_TRADE_GRACE_MINUTES, RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST: config.RECON_EXIT_BACKFILL_PROFILE_ALLOWLIST, ENABLE_RECON_POSITION_PARITY_HEARTBEAT: config.ENABLE_RECON_POSITION_PARITY_HEARTBEAT, RECON_POSITION_PARITY_DRY_RUN: config.RECON_POSITION_PARITY_DRY_RUN, RECON_POSITION_PARITY_CONFIRMATIONS: config.RECON_POSITION_PARITY_CONFIRMATIONS, RECON_POSITION_PARITY_DUST_ABS_QTY: config.RECON_POSITION_PARITY_DUST_ABS_QTY, RECON_POSITION_PARITY_MAX_NOTIONAL_PCT: config.RECON_POSITION_PARITY_MAX_NOTIONAL_PCT, RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION: config.RECON_POSITION_PARITY_REQUIRE_SUBTAG_ATTRIBUTION, ENABLE_RECON_ORDER_COVERAGE_SYNC: config.ENABLE_RECON_ORDER_COVERAGE_SYNC, RECON_ORDER_COVERAGE_DRY_RUN: config.RECON_ORDER_COVERAGE_DRY_RUN, RECON_ORDER_COVERAGE_REQUIRE_PAUSE: config.RECON_ORDER_COVERAGE_REQUIRE_PAUSE, RECON_ORDER_COVERAGE_LOOKBACK_HOURS: config.RECON_ORDER_COVERAGE_LOOKBACK_HOURS, RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE: config.RECON_ORDER_COVERAGE_FETCH_LIMIT_PER_PAGE, RECON_ORDER_COVERAGE_MAX_FETCH_PAGES: config.RECON_ORDER_COVERAGE_MAX_FETCH_PAGES, RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE: config.RECON_ORDER_COVERAGE_MAX_INSERTS_PER_PROFILE, RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS: config.RECON_ORDER_COVERAGE_TRADE_ID_LOOKBACK_ROWS, RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION: config.RECON_ORDER_COVERAGE_REQUIRE_SUBTAG_ATTRIBUTION, RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS: config.RECON_ORDER_COVERAGE_AUTO_PAUSE_ON_UNATTRIBUTED_FILLS, RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT: config.RECON_ORDER_COVERAGE_UNATTRIBUTED_PAUSE_MIN_COUNT, RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS: config.RECON_ORDER_COVERAGE_UNATTRIBUTED_BASELINE_MS, ENABLE_RECON_INTEGRITY_WATCHDOG: config.ENABLE_RECON_INTEGRITY_WATCHDOG, RECON_INTEGRITY_WATCHDOG_THROTTLE_MS: config.RECON_INTEGRITY_WATCHDOG_THROTTLE_MS, RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD: config.RECON_INTEGRITY_WATCHDOG_MISSING_DB_THRESHOLD, RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD: config.RECON_INTEGRITY_WATCHDOG_NO_GO_THRESHOLD, ENABLE_RECON_WATCHDOG_AUTO_RESUME: config.ENABLE_RECON_WATCHDOG_AUTO_RESUME, RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS: config.RECON_WATCHDOG_AUTO_RESUME_MIN_PAUSE_MS, RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES: config.RECON_WATCHDOG_AUTO_RESUME_CLEAN_CYCLES, RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS: config.RECON_WATCHDOG_AUTO_RESUME_COOLDOWN_MS, EXCHANGE_STATE_MISMATCH_THROTTLE_MS: config.EXCHANGE_STATE_MISMATCH_THROTTLE_MS, REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE: config.REQUIRE_EXCHANGE_FILL_EVIDENCE_FOR_AUTO_CLOSE, ENABLE_ALPACA_SUBTAG: config.ENABLE_ALPACA_SUBTAG, SUBTAG_OMNIBUS_ONLY: config.SUBTAG_OMNIBUS_ONLY, ALPACA_SUBTAG_ENV: config.ALPACA_SUBTAG_ENV, ALPACA_SUBTAG_MAX_LENGTH: config.ALPACA_SUBTAG_MAX_LENGTH, ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE: config.ALPACA_SUBTAG_DISABLE_FOR_EXCHANGE, ALPACA_OMNIBUS_PROFILE_ALLOWLIST: config.ALPACA_OMNIBUS_PROFILE_ALLOWLIST, AI_PROVIDER: config.AI.PROVIDER, AI_MODEL: config.AI.MODEL, AI_CONFIDENCE_THRESHOLD: config.AI.CONFIDENCE_THRESHOLD, AI_FALLBACK_LIST: config.AI.FALLBACK_LIST, AI_CACHE_HOURS: config.AI.CACHE_HOURS, AI_FAIL_OPEN: config.AI.FAIL_OPEN, ENABLE_BACKTEST: config.ENABLE_BACKTEST, BACKTEST_CUSTOMER_ENABLED: config.BACKTEST_CUSTOMER_ENABLED, BACKTEST_MAX_CSV_BYTES: config.BACKTEST_MAX_CSV_BYTES, BACKTEST_MAX_ROWS: config.BACKTEST_MAX_ROWS, ENABLED_RULES: config.PRO_STRATEGY.ENABLED_RULES, RISK_PER_TRADE: config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE, RISK_REWARD_RATIO: config.PRO_STRATEGY.PARAMETERS.RISK_REWARD_RATIO, SL_MULTIPLIER: config.PRO_STRATEGY.PARAMETERS.SL_MULTIPLIER, TREND_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.TREND_TIMEFRAME, EXECUTION_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.EXECUTION_TIMEFRAME, MOMENTUM_TIMEFRAME: config.PRO_STRATEGY.PARAMETERS.MOMENTUM_TIMEFRAME, RSI_PERIOD: config.PRO_STRATEGY.PARAMETERS.RSI_PERIOD, RSI_OVERBOUGHT: config.PRO_STRATEGY.PARAMETERS.RSI_OVERBOUGHT, RSI_OVERSOLD: config.PRO_STRATEGY.PARAMETERS.RSI_OVERSOLD, ATR_PERIOD: config.PRO_STRATEGY.PARAMETERS.ATR_PERIOD, }); }); this.app.get('/api/feature-flags', this.requireAuth, (_req, res) => { const flags: TradingFeatureFlagsResponse = { backtest: { enableBacktest: Boolean(config.ENABLE_BACKTEST), customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED), maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES), maxRows: Number(config.BACKTEST_MAX_ROWS), } }; res.json(flags); }); this.app.get('/api/me/profile', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); const profile = await getCurrentUserProfile(authUserId, { email: authReq.authEmail, role: authReq.authRole, first_name: displayNameParts[0] || '', last_name: displayNameParts.slice(1).join(' '), trade_enable: true, }, supabaseService); res.json({ profile }); }); this.app.patch('/api/me/profile', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } const displayNameParts = String(authReq.authDisplayName || '').trim().split(/\s+/).filter(Boolean); try { const profile = await saveCurrentUserProfile(authUserId, req.body || {}, { email: authReq.authEmail, role: authReq.authRole, first_name: displayNameParts[0] || '', last_name: displayNameParts.slice(1).join(' '), trade_enable: true, }, supabaseService); res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile: ${error.message}` }); } }); this.app.get('/api/profiles', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true'; const scope = String(req.query.scope || 'user').toLowerCase(); const wantsAll = scope === 'all'; const isAdmin = wantsAll ? await isTradingAdmin(authUserId, (req as AuthenticatedRequest).authRole) : false; let profiles; if (ensureDefault && !wantsAll) { profiles = await ensureDefaultTradeProfileForUser(authUserId, supabaseService); } else if (wantsAll) { if (!isAdmin) { res.status(403).json({ error: 'Forbidden: Admin role required' }); return; } profiles = await listAllTradeProfiles(supabaseService); } else { profiles = await listTradeProfilesForUser(authUserId, supabaseService); } res.json({ profiles }); } catch (error: any) { res.status(500).json({ error: `Failed to load profiles: ${error.message}` }); } }); this.app.post('/api/profiles', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const requestedUserId = String(req.body?.user_id || '').trim(); const targetUserId = isAdmin && requestedUserId ? requestedUserId : authUserId; const profile = await saveTradeProfileForUser(req.body || {}, targetUserId, supabaseService); res.status(201).json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to save profile: ${error.message}` }); } }); this.app.put('/api/profiles/:id', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const profileId = String(req.params.id || '').trim(); const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const existingProfiles = isAdmin ? await listAllTradeProfiles(supabaseService) : await listTradeProfilesForUser(authUserId, supabaseService); const existing = existingProfiles.find((profile) => profile.id === profileId); if (!existing) { res.status(404).json({ error: 'Profile not found' }); return; } const profile = await saveTradeProfileForUser({ ...existing, ...(req.body || {}), id: profileId, }, existing.user_id, supabaseService); res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile: ${error.message}` }); } }); this.app.patch('/api/profiles/:id/active', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const profileId = String(req.params.id || '').trim(); const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const existingProfiles = isAdmin ? await listAllTradeProfiles(supabaseService) : await listTradeProfilesForUser(authUserId, supabaseService); const existing = existingProfiles .find((profile) => profile.id === profileId); if (!existing) { res.status(404).json({ error: 'Profile not found' }); return; } const profile = await saveTradeProfileForUser({ ...existing, is_active: Boolean(req.body?.is_active), }, existing.user_id, supabaseService); res.json({ profile }); } catch (error: any) { res.status(400).json({ error: `Failed to update profile state: ${error.message}` }); } }); this.app.delete('/api/profiles/:id', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const profileId = String(req.params.id || '').trim(); const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const existingProfiles = isAdmin ? await listAllTradeProfiles(supabaseService) : await listTradeProfilesForUser(authUserId, supabaseService); const existing = existingProfiles.find((profile) => profile.id === profileId); if (!existing) { res.status(404).json({ error: 'Profile not found' }); return; } await deleteTradeProfileForUser(profileId, existing.user_id, supabaseService); res.json({ success: true }); } catch (error: any) { res.status(400).json({ error: `Failed to delete profile: ${error.message}` }); } }); this.app.get('/api/manual-entries', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const entries = await listManualEntriesForUser(authUserId); res.json({ entries }); } catch (error: any) { res.status(500).json({ error: `Failed to load manual entries: ${error.message}` }); } }); this.app.get('/api/positions/bootstrap', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const scope = String(req.query.scope || 'user').toLowerCase(); const wantsAll = scope === 'all' && isAdmin; const orderLimit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000)); const [entries, orders, historyTradeKeys, profiles] = await Promise.all([ listManualEntriesForUser(authUserId), listRecentOrders({ userId: wantsAll ? undefined : authUserId, limit: orderLimit }), listRecentTradeHistoryKeys({ userId: wantsAll ? undefined : authUserId, limit: orderLimit }), wantsAll ? listAllTradeProfiles(supabaseService) : listTradeProfilesForUser(authUserId, supabaseService) ]); res.json({ entries, orders, historyTradeKeys, profiles }); } catch (error: any) { res.status(500).json({ error: `Failed to load positions bootstrap: ${error.message}` }); } }); this.app.get('/api/trade-history', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); const scope = String(req.query.scope || 'user').toLowerCase(); const wantsAll = scope === 'all' && isAdmin; const limit = Math.max(1, Math.min(5000, parseInt(String(req.query.limit || '5000'), 10) || 5000)); const rows = await listRecentTradeHistory({ userId: wantsAll ? undefined : authUserId, limit }); res.json({ rows }); } catch (error: any) { res.status(500).json({ error: `Failed to load trade history: ${error.message}` }); } }); this.app.post('/api/manual-entries', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const entry = await saveManualEntryForUser(authUserId, req.body || {}); res.status(201).json({ entry }); } catch (error: any) { res.status(400).json({ error: `Failed to save manual entry: ${error.message}` }); } }); this.app.put('/api/manual-entries/:id', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const entry = await saveManualEntryForUser(authUserId, { ...(req.body || {}), stock_instance_id: String(req.params.id || '').trim() }); res.json({ entry }); } catch (error: any) { res.status(400).json({ error: `Failed to update manual entry: ${error.message}` }); } }); this.app.delete('/api/manual-entries/:id', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim()); res.json({ success: true }); } catch (error: any) { res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` }); } }); this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => { try { const items = await listDynamicConfigEntries(); res.json({ items }); } catch (error: any) { res.status(500).json({ error: `Failed to load dynamic config: ${error.message}` }); } }); this.app.put('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (req, res) => { try { const items = Array.isArray(req.body?.items) ? req.body.items : []; await upsertDynamicConfigEntries(items); applyDynamicConfigEntries(items); await loadDynamicConfig(); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: `Failed to update dynamic config: ${error.message}` }); } }); this.app.get('/api/marketplace-presets', this.requireAuth, async (_req, res) => { try { const presets = await listStrategyPresets(supabaseService); res.json({ presets }); } catch (error: any) { res.status(500).json({ error: `Failed to load marketplace presets: ${error.message}` }); } }); this.app.post('/api/marketplace-presets', this.requireAuth, this.requireAdmin, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!authUserId) { res.status(401).json({ error: 'Unauthorized' }); return; } try { const payload = { ...(req.body || {}), created_by: authUserId }; await createStrategyPreset(payload, supabaseService); res.status(201).json({ success: true }); } catch (error: any) { res.status(400).json({ error: `Failed to publish preset: ${error.message}` }); } }); this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => { const authReq = req as AuthenticatedRequest; const authUserId = authReq.authUserId; if (!authUserId) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'backtest')) { return; } if (!config.ENABLE_BACKTEST) { res.status(404).json({ success: false, error: 'Backtest feature is disabled' }); return; } const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); if (!isAdmin && !config.BACKTEST_CUSTOMER_ENABLED) { res.status(403).json({ success: false, error: 'Backtest is restricted to admin users. Ask an admin to enable customer backtest access.' }); return; } try { const body = req.body || {}; const profileId = String(body.profileId || '').trim(); let profileSettings: any = undefined; if (profileId) { profileSettings = await getTradeProfileForUser(profileId, authUserId, supabaseService); if (!profileSettings) { res.status(404).json({ success: false, error: 'Backtest profile not found for current user' }); return; } } const symbols = this.parseSymbolList(body.symbols || profileSettings?.symbols); if (!symbols.length) { res.status(400).json({ success: false, error: 'At least one symbol is required for backtest.' }); return; } const strategyConfig = body.strategyConfig || profileSettings?.strategy_config; if (!strategyConfig) { res.status(400).json({ success: false, error: 'strategyConfig is required (or supply profileId with saved strategy).' }); return; } this.enforceBacktestPayloadGuards(body.dataSource); const timeframe = this.normalizeBacktestTimeframe(body.timeframe); const backtestRequest: BacktestRequest = { mode: String(body.mode || 'backtest') as BacktestRequest['mode'], profileId: profileId || undefined, strategyConfig, symbols, timeframe, dateRange: { from: String(body?.dateRange?.from || ''), to: String(body?.dateRange?.to || '') }, dataSource: body.dataSource, execution: body.execution }; const executionProfileSettings = { ...profileSettings, strategy_config: strategyConfig, symbols: symbols.join(','), allocated_capital: Number(body?.execution?.initialCapitalUsd ?? (profileSettings?.allocated_capital || config.TOTAL_CAPITAL)), risk_per_trade_percent: Number(profileSettings?.risk_per_trade_percent || config.PRO_STRATEGY.PARAMETERS.RISK_PER_TRADE * 100) }; const result = await runBacktest(backtestRequest, { profileSettings: executionProfileSettings }); this.auditTradeEvent({ event: 'backtest_run', userId: authUserId, profileId: profileId || undefined, outcome: 'accepted', details: { symbols, timeframe, from: backtestRequest.dateRange.from, to: backtestRequest.dateRange.to } }); res.json({ success: true, result }); } catch (error: any) { this.auditTradeEvent({ event: 'backtest_run', userId: authUserId, profileId: String(req.body?.profileId || '').trim() || undefined, outcome: 'error', reason: error.message }); res.status(400).json({ success: false, error: error.message || 'Backtest run failed' }); } }); // --- AI Health Endpoint --- this.app.get('/api/ai/health', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'chat')) { return; } const probeParam = String(req.query.probe || '').toLowerCase(); const probe = probeParam === '1' || probeParam === 'true' || probeParam === 'yes'; try { const providers = await this.aiClient.getProviderHealth(probe); const configuredProviders = providers .filter((p) => p.configured) .map((p) => p.provider); const healthyProviders = providers .filter((p) => p.status === 'ok' || p.status === 'configured') .map((p) => p.provider); this.auditTradeEvent({ event: 'ai_health_check', userId: authUserId, outcome: 'accepted', details: { probe, configuredProviders, healthyProviders } }); res.json({ success: true, probe, failOpen: config.AI.FAIL_OPEN, fallbackList: config.AI.FALLBACK_LIST, providers, summary: { configuredCount: configuredProviders.length, healthyCount: healthyProviders.length, anyUsable: healthyProviders.length > 0 } }); } catch (error: any) { this.auditTradeEvent({ event: 'ai_health_check', userId: authUserId, outcome: 'error', reason: error.message }); res.status(500).json({ success: false, error: `AI health check failed: ${error.message}` }); } }); // --- NEW: Manual Trade Execution Endpoint --- this.app.post('/api/trade', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; const { profile_id, symbol, side, qty, type, price, sl, tp } = req.body; if (!symbol || !side || !qty) { return res.status(400).json({ success: false, error: "Missing required fields" }); } if (!authUserId) { return res.status(401).json({ success: false, error: "Unauthorized" }); } if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'trade')) { return; } this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, details: { side, qty, type: type || 'market' } }); // Find manager let manager = profile_id ? this.executionManagers.get(profile_id) : undefined; if (manager && manager.getUserId() !== authUserId) { this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: 'profile ownership mismatch' }); return res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to authenticated user' }); } if (!manager && !profile_id) { const userManagers = Array.from(this.executionManagers.values()).filter(m => m.getUserId() === authUserId); if (userManagers.length === 1) { manager = userManagers[0]; } else if (userManagers.length > 1) { return res.status(400).json({ success: false, error: 'Multiple profiles found. Please provide profile_id.' }); } } if (!manager) { this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: 'manual trader unavailable' }); return res.status(503).json({ error: 'No Manual Trader available.' }); } try { const livePrice = Number(this.state.symbols[symbol]?.price || 0); const priceHint = Number(price) > 0 ? Number(price) : (livePrice > 0 ? livePrice : undefined); const result = await manager.executeRequest( symbol, side, qty, type || 'market', price, priceHint, authUserId, sl, tp ); if (result.success) { this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'accepted', details: { orderId: result.orderId } }); res.json(result); } else { this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: result.error || 'execution returned failure' }); res.status(500).json(result); } } catch (error: any) { this.auditTradeEvent({ event: 'trade_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'error', reason: error.message }); res.status(500).json({ error: error.message }); } }); // --- NEW: Close Position Endpoint (Square Off) --- this.app.post('/api/close', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; const { profile_id, symbol } = req.body; logger.info(`[API] Received Square Off request for ${symbol} (Profile: ${profile_id}, AuthUser: ${authUserId})`); if (!symbol) { return res.status(400).json({ success: false, error: "Missing symbol" }); } if (!authUserId) { return res.status(401).json({ success: false, error: "Unauthorized" }); } if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'close')) { return; } this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol }); // Find manager let manager = profile_id ? this.executionManagers.get(profile_id) : undefined; if (manager && manager.getUserId() !== authUserId) { this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: 'profile ownership mismatch' }); return res.status(403).json({ success: false, error: 'Forbidden: profile does not belong to authenticated user' }); } if (!manager) { const userManagers = Array.from(this.executionManagers.values()).filter(m => m.getUserId() === authUserId); const matchingManagers = userManagers.filter(m => !!m.getActivePosition(symbol)); if (matchingManagers.length > 1) { return res.status(400).json({ success: false, error: 'Multiple matching positions found. Please pass profile_id.' }); } manager = matchingManagers[0]; } if (!manager) { this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: 'execution manager unavailable' }); return res.status(503).json({ error: 'No Execution Manager available.' }); } const activePos = manager.getActivePosition(symbol); if (!activePos) { this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'rejected', reason: 'no active position' }); return res.status(404).json({ error: 'No active position found for this symbol.' }); } try { // Get current price from API state const currentPrice = this.state.symbols[symbol]?.price || 0; const symbolPositions = manager.getActivePositions(symbol); for (const pos of symbolPositions) { await manager.executeExit(symbol, currentPrice, 'Manual Square Off', pos.tradeId); } this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'accepted' }); res.json({ success: true, message: `Squared off ${symbol}` }); } catch (error: any) { this.auditTradeEvent({ event: 'close_request', userId: authUserId, profileId: profile_id, symbol, outcome: 'error', reason: error.message }); res.status(500).json({ success: false, error: error.message }); } }); // --- NEW: Clear Operational Events --- this.app.delete('/api/events', this.requireAuth, this.requireAdmin, async (req, res) => { try { observabilityService.clearEvents(); this.state.operationalEvents = []; this.emitToConnectedUsers('operational_event_cleared', () => ({ success: true })); res.json({ success: true, message: 'Operational events cleared' }); } catch (error: any) { res.status(500).json({ success: false, error: error.message }); } }); // --- Reconciliation EXIT Backfill Audit (Admin) --- this.app.get('/api/reconciliation/backfill/audit', this.requireAuth, this.requireAdmin, async (req, res) => { try { const profileId = String(req.query.profileId || '').trim(); const symbol = String(req.query.symbol || '').trim(); const batchId = String(req.query.batchId || '').trim(); const fromParam = String(req.query.from || '').trim(); const toParam = String(req.query.to || '').trim(); const days = Math.max(0, Math.min(365, Number.parseInt(String(req.query.days || '7'), 10) || 0)); const limit = Math.max(1, Math.min(500, Number.parseInt(String(req.query.limit || '100'), 10) || 100)); const offset = Math.max(0, Number.parseInt(String(req.query.offset || '0'), 10) || 0); const decisionParam = String(req.query.decision || '').trim(); const decisions = decisionParam ? decisionParam.split(',').map((value) => value.trim()).filter(Boolean) : []; let fromIso = fromParam; if (!fromIso && days > 0) { fromIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); } const toIso = toParam || undefined; const result = await runtimeOrderRepository.getReconciliationBackfillAuditRows({ profileId: profileId || undefined, symbol: symbol || undefined, batchId: batchId || undefined, decisions, fromIso, toIso, limit, offset }); res.json({ success: true, filters: { profileId: profileId || null, symbol: symbol || null, batchId: batchId || null, decisions, from: fromIso || null, to: toIso || null, days }, pagination: { limit, offset, totalCount: result.totalCount, hasMore: offset + result.rows.length < result.totalCount }, rows: result.rows }); } catch (error: any) { res.status(500).json({ success: false, error: error.message }); } }); this.app.get('/api/reconciliation/backfill/batches', this.requireAuth, this.requireAdmin, async (req, res) => { try { const profileId = String(req.query.profileId || '').trim(); const symbol = String(req.query.symbol || '').trim(); const fromParam = String(req.query.from || '').trim(); const toParam = String(req.query.to || '').trim(); const days = Math.max(0, Math.min(365, Number.parseInt(String(req.query.days || '7'), 10) || 0)); const limit = Math.max(1, Math.min(100, Number.parseInt(String(req.query.limit || '20'), 10) || 20)); let fromIso = fromParam; if (!fromIso && days > 0) { fromIso = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); } const toIso = toParam || undefined; const batches = await runtimeOrderRepository.getReconciliationBackfillBatchSummaries({ profileId: profileId || undefined, symbol: symbol || undefined, fromIso, toIso, limit }); res.json({ success: true, filters: { profileId: profileId || null, symbol: symbol || null, from: fromIso || null, to: toIso || null, days }, batches }); } catch (error: any) { res.status(500).json({ success: false, error: error.message }); } }); // --- Chat-based Profile Control --- this.app.post('/api/chat', this.requireAuth, async (req, res) => { const authUserId = (req as AuthenticatedRequest).authUserId; if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'chat')) { return; } const { message, context } = req.body; if (!message) { return res.status(400).json({ error: 'Message is required' }); } this.auditTradeEvent({ event: 'chat_profile_control', userId: authUserId, details: { messageLength: typeof message === 'string' ? message.length : 0 } }); const systemPrompt = `You are the AI assistant for the Bytelyst Trading Platform. You translate plain English instructions into structured trading profile configurations. AVAILABLE RULES (use these exact ruleId values): - TrendBiasRule: EMA50/200 trend direction check. Params: { fastPeriod: number, slowPeriod: number } - MomentumRule: RSI overbought/oversold logic. Params: { rsiPeriod: number, overbought: number, oversold: number } - ZoneRule: Price proximity to EMA zones. Params: { zonePercent: number } - SessionRule: Trading session filter. Params: { sessions: string (comma-separated: "London,NY,Tokyo,Sydney") } - EntryTriggerRule: Pattern-based entry. Params: { showPatterns: boolean } - RiskManagementRule: ATR-based risk limits. Params: { maxRisk: number } - AIAnalysisRule: LLM sentiment analysis. Params: { minConfidence: number (0-1) } PROFILE SCHEMA: { "action": "create_profile" | "update_profile" | "explain", "profile": { "name": string, "allocated_capital": number, "risk_per_trade_percent": number, "symbols": string (comma-separated, e.g. "BTC/USDT, ETH/USDT"), "is_active": boolean, "strategy_config": { "rules": [ { "ruleId": string, "enabled": boolean, "params": {...} } ], "riskLimits": { "maxDailyLossUsd": number, "maxOpenTrades": number, "maxConsecutiveLosses": number }, "execution": { "orderType": "market" | "limit", "cooldownMinutes": number, "entryMode": "both" | "long_only" } } }, "summary": string (1-2 sentence human-readable summary of what you did), "reasoning": string (brief explanation of why you chose these parameters) } CURRENT CONTEXT (existing profiles): ${context ? JSON.stringify(context, null, 2) : 'No existing profiles.'} RULES: 1. For "create_profile": generate a complete profile with sensible defaults based on the user's description. 2. For "update_profile": include the profile "id" field and only change what the user asked for. Keep everything else the same. 3. For "explain": just set action to "explain" and put your answer in "summary". No profile needed. 4. Match the user's risk appetite: "conservative" = low risk (0.5-1%), low capital. "aggressive" = higher risk (2-5%), more rules enabled. 5. Always include at least TrendBiasRule and RiskManagementRule as enabled for safety. 6. Output ONLY valid JSON. No markdown, no backticks, no explanation outside the JSON.`; try { let aiResponse: string | null = null; try { aiResponse = await this.aiClient.generateAnalysis( `${systemPrompt}\n\nUser message: "${message}"` ); } catch (aiError: any) { logger.error(`[Chat] AI provider chain failed: ${aiError.message}`); } if (!aiResponse) { const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); this.auditTradeEvent({ event: 'chat_profile_control', userId: authUserId, outcome: 'accepted', details: { action: fallback.action, fallback: 'local_deterministic' } }); return res.json(fallback); } // Parse the JSON from AI response (handle markdown code blocks if present) let parsed: any; try { const cleaned = aiResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); parsed = JSON.parse(cleaned); } catch (parseErr) { logger.error(`[Chat] Failed to parse AI response: ${aiResponse}`); const fallback = this.buildLocalChatFallback(message, Array.isArray(context) ? context : []); return res.json({ ...fallback, reasoning: `${fallback.reasoning} AI output was non-JSON, so local fallback parsing was used.` }); } logger.info(`[Chat] Action: ${parsed.action}, Summary: ${parsed.summary}`); this.auditTradeEvent({ event: 'chat_profile_control', userId: authUserId, outcome: 'accepted', details: { action: parsed.action } }); res.json(parsed); } catch (error: any) { logger.error(`[Chat] Error: ${error.message}`); this.auditTradeEvent({ event: 'chat_profile_control', userId: authUserId, outcome: 'error', reason: error.message }); res.status(500).json({ error: `Chat failed: ${error.message}` }); } }); } private setupSocketHandlers() { this.io.use(async (socket, next) => { const authToken = typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : this.extractBearerToken(socket.handshake.headers.authorization); if (!authToken) { next(new Error('Unauthorized: missing token')); return; } const { userId, role, error } = await verifyTradingAccessToken(authToken); if (!userId) { next(new Error(`Unauthorized: ${error || 'invalid token'}`)); return; } socket.data.userId = userId; socket.data.authRole = role; socket.data.isAdmin = await isTradingAdmin(userId, role); next(); }); this.io.on('connection', (socket) => { const userId = String(socket.data.userId || '').trim(); logger.info(`[API] Dashboard connected: ${socket.id} (user: ${userId || 'unknown'})`); if (userId) { this.trackSocket(userId, socket); const scopedState = this.getScopedState(userId, !!socket.data.isAdmin); socket.emit('state', scopedState); } else { socket.emit('state', { symbols: {}, positions: [], alerts: [], orders: [], history: [], settings: this.state.settings, health: healthTracker.getSnapshot(), uptime: this.state.uptime, accountSnapshot: null, orderFailures: [], operationalEvents: [] } as BotState); } socket.on('disconnect', () => { if (userId) { this.untrackSocket(userId, socket.id); } logger.info(`[API] Dashboard disconnected: ${socket.id}`); }); }); } private startServer() { this.httpServer.listen(this.port, () => { logger.info(`[API] Server running on port ${this.port}`); }); } public updateSymbol(symbol: string, data: Partial) { if (!this.state.symbols[symbol]) { this.state.symbols[symbol] = { price: 0, change24h: 0, changeToday: 0, session: 'Unknown', volatility: 'Low', signal: 'NONE', tradingMode: 'Alerts', activePosition: null, priceHistory: [], rules: {}, profileSignals: {}, indicators: {} }; } if (data.price !== undefined) { this.state.symbols[symbol].priceHistory.push({ timestamp: Date.now(), price: data.price }); if (this.state.symbols[symbol].priceHistory.length > 60) { this.state.symbols[symbol].priceHistory = this.state.symbols[symbol].priceHistory.slice(-60); } } this.state.symbols[symbol] = { ...this.state.symbols[symbol], ...data }; // --- NEW: Real-time P/L Update for Positions --- if (data.price) { let positionsChanged = false; const currentPrice = data.price; this.state.positions = this.state.positions.map(pos => { if (pos.symbol === symbol) { positionsChanged = true; // Calculate P/L const diff = currentPrice - pos.entryPrice; const direction = pos.side === 'BUY' ? 1 : -1; const pnl = diff * pos.size * direction; const pnlPercent = (diff / pos.entryPrice) * 100 * direction; return { ...pos, currentPrice: currentPrice, unrealizedPnl: Number(pnl.toFixed(2)), unrealizedPnlPercent: Number(pnlPercent.toFixed(2)), marketValue: Number((pos.size * currentPrice).toFixed(2)) }; } return pos; }); if (positionsChanged) { this.broadcastPositionsUpdate(); } } this.broadcastSymbolUpdate(symbol); this.saveState(); } public addAlert( type: BotState['alerts'][0]['type'], symbol: string, message: string, context?: { userId?: string; profileId?: string } ) { const alert = { timestamp: Date.now(), type, symbol, message, userId: context?.userId, profileId: context?.profileId }; this.state.alerts.push(alert); if (this.state.alerts.length > 100) { this.state.alerts = this.state.alerts.slice(-100); } const owner = this.resolveProfileOwner(alert.profileId, alert.userId); if (owner) { this.emitToUser(owner, 'new_alert', alert); } else { this.emitToConnectedUsers('new_alert', () => alert); } this.saveState(); } public updatePositions(positions: BotState['positions'], sourceId: string = 'global') { const sourceProfileId = sourceId === 'global' ? undefined : sourceId; // Enrich incoming positions with current P/L calculations before storing const enrichedPositions = positions.map(pos => { const currentPrice = this.state.symbols[pos.symbol]?.price || pos.entryPrice; const diff = currentPrice - pos.entryPrice; const direction = pos.side === 'BUY' ? 1 : -1; const pnl = diff * pos.size * direction; const pnlPercent = (diff / pos.entryPrice) * 100 * direction; const resolvedUserId = this.resolveProfileOwner(pos.profileId || sourceProfileId, pos.userId); return { ...pos, currentPrice: currentPrice, unrealizedPnl: Number(pnl.toFixed(2)), unrealizedPnlPercent: Number(pnlPercent.toFixed(2)), marketValue: Number((pos.size * currentPrice).toFixed(2)), userId: pos.userId || resolvedUserId || undefined, profileId: pos.profileId || sourceProfileId }; }); // Global updates are full snapshots from the trading loop; treat them as authoritative. if (sourceId === 'global') { this.profilePositionsList.clear(); } this.profilePositionsList.set(sourceId, enrichedPositions); const merged = mergePositionSnapshots(Array.from(this.profilePositionsList.values())); this.state.positions = merged; this.broadcastPositionsUpdate(); this.saveState(); } public updateOrders(orders: BotState['orders'], sourceId: string = 'global') { const inferredProfileId = sourceId === 'global' ? undefined : sourceId; const normalizedOrders = orders.map((order) => ({ ...order, trade_id: order.trade_id || (order as any).tradeId, subTag: order.subTag || (order as any).subtag || (order as any).sub_tag, profileId: order.profileId || inferredProfileId, userId: order.userId || this.resolveProfileOwner(order.profileId || inferredProfileId), source: order.source || ((order.profileId || inferredProfileId) ? 'BOT' : 'MANUAL') })); if (sourceId === 'global') { this.profileOrdersList.clear(); } this.profileOrdersList.set(sourceId, normalizedOrders); const merged = mergeOrderSnapshots(Array.from(this.profileOrdersList.values())); this.state.orders = merged; this.broadcastOrdersUpdate(); this.saveState(); } public addHistory(trade: BotState['history'][0]) { const resolvedUserId = this.resolveProfileOwner(trade.profileId, trade.userId); const normalizedTrade: BotState['history'][0] = { ...trade, userId: trade.userId || resolvedUserId || undefined, source: trade.source || (trade.profileId ? 'BOT' : 'MANUAL') }; this.state.history.push(normalizedTrade); if (this.state.history.length > 100) { this.state.history = this.state.history.slice(-100); } this.broadcastHistoryUpdate(normalizedTrade); this.saveState(); } public updateAccountSnapshot(snapshot: AccountSnapshot) { const enrichedSnapshot: AccountSnapshot = { ...snapshot, timestamp: snapshot.timestamp || Date.now() }; this.state.accountSnapshot = enrichedSnapshot; this.accountSnapshotCache = [...this.accountSnapshotCache, enrichedSnapshot].slice(-25); const owner = this.resolveProfileOwner(snapshot.profileId, snapshot.userId); if (owner) { this.emitToUser(owner, 'account_snapshot', enrichedSnapshot); } else { this.emitToConnectedUsers('account_snapshot', () => enrichedSnapshot); } } public broadcast(event: string, data: any) { this.emitToConnectedUsers(event, () => data); } public recordOrderFailure(failure: OrderFailureRecord) { const enrichedFailure: OrderFailureRecord = { ...failure, timestamp: failure.timestamp || Date.now() }; this.state.orderFailures = [...this.state.orderFailures, enrichedFailure].slice(-30); const owner = this.resolveProfileOwner(failure.profileId, failure.userId); if (owner) { this.emitToUser(owner, 'order_failure', enrichedFailure); } else { this.emitToConnectedUsers('order_failure', () => enrichedFailure); } } public updateSettings(settings: Partial) { this.state.settings = { ...this.state.settings, ...settings }; this.broadcastSettingsUpdate(); this.saveState(); } public updateRuntimeHealth(update: Partial) { this.runtimeHealth = { ...this.runtimeHealth, ...update }; } public publishHealthSnapshot(options?: { broadcast?: boolean; force?: boolean }): HealthSnapshot { const snapshot = healthTracker.getSnapshot(); this.state.health = snapshot; if (options?.broadcast) { const now = Date.now(); const shouldBroadcast = options.force || (now - this.lastHealthBroadcastAt) >= this.healthBroadcastMinIntervalMs; if (shouldBroadcast) { this.lastHealthBroadcastAt = now; this.emitToConnectedUsers('health_update', () => snapshot); } } return snapshot; } public pruneSymbols(activeSymbols: string[]) { const currentSymbols = Object.keys(this.state.symbols); let changed = false; currentSymbols.forEach(s => { if (!activeSymbols.includes(s)) { delete this.state.symbols[s]; changed = true; logger.info(`[API] Pruned legacy symbol: ${s}`); } }); if (changed) { this.saveState(); } } public getState(): BotState { return this.state; } public async stop(): Promise { if (!this.httpServer.listening) { return; } this.io.close(); await new Promise((resolve, reject) => { this.httpServer.close((err?: Error) => { if (err) { reject(err); return; } resolve(); }); }); } }