learning_ai_invt_trdg/backend/src/services/apiServer.ts
Saravana Achu Mac 1377bf2453 fix(C6): require explicit FMP API key
Remove the silent shared demo-key fallback for FMP-backed research and screener routes, document the required key, and make backend/.env.example trackable so setup guidance has one source of truth.

Refs: docs/AUDIT_REDESIGN.md item C6.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
2026-05-04 17:01:00 -07:00

3255 lines
139 KiB
TypeScript

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 { 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 {
assertBacktestBodyDoesNotContainInlineCode,
assertBacktestStrategyConfigSafe,
UnsafeCodeStrategyError
} from '../backtest/strategySafety.js';
import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js';
import { fetchFmpJson, FmpFetchError } from './fmpCache.js';
import {
canonicalLifecycleService,
type CanonicalLifecycleProfileMeta
} from './canonicalLifecycleService.js';
import type { TradingFeatureFlagsResponse } from '../../../shared/feature-flags.js';
import { SOCKET_NAMESPACES } from '../../../shared/realtime.js';
import { persistAuditEvent, listAuditEvents } from './auditRepository.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;
}
const ALLOWED_SCREENER_SECTORS = new Set([
'Technology',
'Financial Services',
'Healthcare',
'Consumer Cyclical',
'Consumer Defensive',
'Industrials',
'Energy',
'Utilities',
'Real Estate',
'Communication Services',
'Basic Materials',
]);
const NEWS_SYMBOL_PATTERN = /^[A-Z0-9][A-Z0-9./-]{0,15}$/;
const MAX_NEWS_SYMBOLS = 10;
const normalizeNewsSymbolsQuery = (value: unknown): string => {
const raw = String(value || '').trim();
if (!raw) return '';
const symbols = raw
.split(',')
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean);
if (symbols.length > MAX_NEWS_SYMBOLS || symbols.some((symbol) => !NEWS_SYMBOL_PATTERN.test(symbol))) {
throw new Error('Invalid news symbols query');
}
return symbols.join(',');
};
const getConfiguredFmpApiKey = (): string => {
const apiKey = config.FMP_API_KEY.trim();
if (!apiKey || apiKey.toLowerCase() === 'demo') {
throw new Error('FMP_API_KEY is required for research and screener endpoints');
}
return apiKey;
};
interface TradeAuditEvent {
event: string;
userId?: string;
profileId?: string;
symbol?: string;
outcome?: 'accepted' | 'rejected' | 'error';
reason?: string;
details?: Record<string, unknown>;
}
interface ChatProfileRuleConfig {
ruleId: string;
enabled: boolean;
params: Record<string, any>;
}
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<string, ManualTrader> = new Map(); // profileId -> ManualTrader
private profileOwners: Map<string, string> = new Map(); // profileId -> userId
private socketsByUser: Map<string, Set<string>> = new Map(); // userId -> socket ids
private aiClient = new AIClient();
private profilePositionsList = new Map<string, any[]>();
private profileOrdersList = new Map<string, any[]>();
private snapshotOwnerId: string | null = null;
private snapshotWriteTimer: NodeJS.Timeout | null = null;
private isSnapshotWriteInFlight = false;
private snapshotWriteQueued = false;
private lastSnapshotWriteAt = 0;
private rateLimitBuckets: Map<string, RateLimitBucket> = 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<string, number>();
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<string>();
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<Record<string, any>>((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<BotState['symbols']>((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<T>(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<void> {
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<void> => {
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<void> => {
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<void> {
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)}`);
// Persist to Cosmos audit-events container (best-effort — never throws).
void persistAuditEvent(evt);
}
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<string>();
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<string | null> {
if (this.snapshotOwnerId) return this.snapshotOwnerId;
const owner = await resolveSnapshotOwnerIdFromRepository();
this.snapshotOwnerId = owner;
return owner;
}
private async restoreStateFromLatestSnapshot(): Promise<void> {
try {
const ownerId = await this.resolveSnapshotOwnerId();
if (!ownerId) {
logger.warn('[API] Snapshot owner not resolved; skipping snapshot restore.');
} else {
const snapshot = await loadLatestBotStateSnapshotFromRepository(ownerId);
if (snapshot && snapshot.state) {
const restoredState = snapshot.state as Partial<BotState>;
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 snapshot repository:', 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<void> {
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());
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()
: await listTradeProfilesForUser(authUserId);
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<string, any>();
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<Record<string, number>>((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 authReq = req as AuthenticatedRequest;
const authUserId = authReq.authUserId;
if (!authUserId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const limit = parseInt(req.query.limit as string) || 50;
const isAdmin = await isTradingAdmin(authUserId, authReq.authRole);
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),
},
tabs: {
marketplace: Boolean(config.TAB_MARKETPLACE_ENABLED),
membership: Boolean(config.TAB_MEMBERSHIP_ENABLED),
},
};
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,
});
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,
});
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);
} else if (wantsAll) {
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin role required' });
return;
}
profiles = await listAllTradeProfiles();
} else {
profiles = await listTradeProfilesForUser(authUserId);
}
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);
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()
: await listTradeProfilesForUser(authUserId);
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);
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()
: await listTradeProfilesForUser(authUserId);
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);
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()
: await listTradeProfilesForUser(authUserId);
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);
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()
: listTradeProfilesForUser(authUserId)
]);
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();
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);
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 || {};
assertBacktestBodyDoesNotContainInlineCode(body);
const profileId = String(body.profileId || '').trim();
let profileSettings: any = undefined;
if (profileId) {
profileSettings = await getTradeProfileForUser(profileId, authUserId);
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;
}
assertBacktestStrategyConfigSafe(strategyConfig);
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) {
const isUnsafeCodeStrategy = error instanceof UnsafeCodeStrategyError;
this.auditTradeEvent({
event: 'backtest_run',
userId: authUserId,
profileId: String(req.body?.profileId || '').trim() || undefined,
outcome: isUnsafeCodeStrategy ? 'rejected' : '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 });
}
});
// --- Admin Audit Event Log ---
this.app.get('/api/admin/audit', this.requireAuth, this.requireAdmin, async (req, res) => {
try {
const userId = String(req.query.userId || '').trim() || undefined;
const event = String(req.query.event || '').trim() || undefined;
const sinceMs = req.query.since ? Number(req.query.since) : undefined;
const limit = Math.max(1, Math.min(500, Number.parseInt(String(req.query.limit || '100'), 10) || 100));
const records = await listAuditEvents({ userId, event, sinceMs, limit });
res.json({ records, count: records.length });
} catch (error: any) {
res.status(500).json({ 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}` });
}
});
// ══════════════════════════════════════════════════════════════════════
// MARKET DATA PROXY ENDPOINTS (Phase 3-6 of web dashboard redesign)
// ══════════════════════════════════════════════════════════════════════
// ── Chart bars: Alpaca stock bars with period mapping ────────────────
this.app.get('/api/chart/bars', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
const period = String(req.query.period || '1Y').trim();
const alpacaKey = config.ALPACA_API_KEY;
const alpacaSecret = config.ALPACA_API_SECRET;
if (!symbol) return res.status(400).json({ error: 'symbol required' });
if (!alpacaKey || !alpacaSecret) return res.status(503).json({ error: 'Alpaca credentials not configured' });
const now = new Date();
let start = new Date(now);
let timeframe = '1Day';
let limit = 365;
switch (period) {
case '1D': start.setHours(0,0,0,0); timeframe = '5Min'; limit = 100; break;
case '5D': start.setDate(now.getDate() - 5); timeframe = '15Min'; limit = 200; break;
case '1M': start.setMonth(now.getMonth() - 1); timeframe = '1Day'; limit = 31; break;
case '3M': start.setMonth(now.getMonth() - 3); timeframe = '1Day'; limit = 92; break;
case '6M': start.setMonth(now.getMonth() - 6); timeframe = '1Day'; limit = 183; break;
case 'YTD': start = new Date(now.getFullYear(), 0, 1); timeframe = '1Day'; limit = 365; break;
case '1Y': start.setFullYear(now.getFullYear() - 1); timeframe = '1Day'; limit = 365; break;
case '5Y': start.setFullYear(now.getFullYear() - 5); timeframe = '1Week'; limit = 260; break;
case 'MAX': start.setFullYear(now.getFullYear() - 20); timeframe = '1Month';limit = 240; break;
default: start.setFullYear(now.getFullYear() - 1); timeframe = '1Day'; limit = 365; break;
}
// Detect crypto (contains "/" like BTC/USD or BTC/USDT)
const isCrypto = symbol.includes('/');
const encodedSymbol = encodeURIComponent(symbol);
let url: string;
const qs = new URLSearchParams({
start: start.toISOString(),
end: now.toISOString(),
timeframe,
limit: String(limit),
sort: 'asc',
});
if (isCrypto) {
url = `https://data.alpaca.markets/v1beta3/crypto/us/bars?symbols=${encodedSymbol}&${qs.toString()}`;
} else {
qs.set('feed', 'iex');
url = `https://data.alpaca.markets/v2/stocks/${encodedSymbol}/bars?${qs.toString()}`;
}
const r = await fetch(url, {
headers: {
'APCA-API-KEY-ID': alpacaKey,
'APCA-API-SECRET-KEY': alpacaSecret,
},
});
if (!r.ok) {
const txt = await r.text().catch(() => '');
return res.status(r.status).json({ error: `Alpaca bars fetch failed: ${txt}` });
}
const data = await r.json() as any;
// Crypto response: { bars: { "BTC/USD": [...] } }, stocks: { bars: [...] }
let rawBars: any[];
if (isCrypto) {
const cryptoBars = data.bars ?? {};
rawBars = cryptoBars[symbol] ?? Object.values(cryptoBars)[0] ?? [];
} else {
rawBars = data.bars ?? [];
}
const bars = rawBars.map((b: any) => ({
ts: new Date(b.t).getTime(),
open: b.o,
high: b.h,
low: b.l,
close: b.c,
volume:b.v,
}));
res.json({ symbol, period, bars });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── News: proxy to Alpaca /v1beta1/news ───────────────────────────────
this.app.get('/api/news', this.requireAuth, async (req, res) => {
try {
let symbols = '';
try {
symbols = normalizeNewsSymbolsQuery(req.query.symbols);
} catch (validationError: any) {
return res.status(400).json({ error: validationError.message });
}
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 10));
const alpacaKey = config.ALPACA_API_KEY;
const alpacaSecret = config.ALPACA_API_SECRET;
if (!alpacaKey || !alpacaSecret) {
return res.status(503).json({ error: 'Alpaca credentials not configured' });
}
const qs = new URLSearchParams({
...(symbols ? { symbols } : {}),
limit: String(limit),
sort: 'desc',
});
const url = `https://data.alpaca.markets/v1beta1/news?${qs.toString()}`;
const r = await fetch(url, {
headers: {
'APCA-API-KEY-ID': alpacaKey,
'APCA-API-SECRET-KEY': alpacaSecret,
},
});
if (!r.ok) return res.status(r.status).json({ error: 'Alpaca news fetch failed' });
const data = await r.json() as any;
res.json({ news: data.news ?? data });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Market indices: SPY / DIA / QQQ snapshots from Alpaca ─────────────
this.app.get('/api/market/indices', this.requireAuth, async (req, res) => {
try {
const alpacaKey = config.ALPACA_API_KEY;
const alpacaSecret = config.ALPACA_API_SECRET;
if (!alpacaKey || !alpacaSecret) {
return res.status(503).json({ error: 'Alpaca credentials not configured' });
}
const url = 'https://data.alpaca.markets/v2/stocks/snapshots?symbols=SPY,DIA,QQQ&feed=iex';
const r = await fetch(url, {
headers: {
'APCA-API-KEY-ID': alpacaKey,
'APCA-API-SECRET-KEY': alpacaSecret,
},
});
if (!r.ok) return res.status(r.status).json({ error: 'Alpaca snapshots fetch failed' });
const data = await r.json() as any;
res.json(data);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Research: company profile from FMP ───────────────────────────────
this.app.get('/api/research/profile', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP profile fetch failed' });
}
res.status(500).json({ error: error.message });
}
});
// ── Research: key metrics (P/E, ROE, etc.) from FMP ──────────────────
this.app.get('/api/research/metrics', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP metrics fetch failed' });
}
res.status(500).json({ error: error.message });
}
});
// ── Research: earnings calendar from FMP ──────────────────────────────
this.app.get('/api/research/earnings', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = getConfiguredFmpApiKey();
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
const data = await fetchFmpJson(url) as any;
res.json({ earnings: Array.isArray(data) ? data : [] });
} catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP earnings fetch failed' });
}
res.status(500).json({ error: error.message });
}
});
// ── Screener: stock screener from FMP ────────────────────────────────
this.app.get('/api/screener', this.requireAuth, async (req, res) => {
try {
const apiKey = getConfiguredFmpApiKey();
const qs = new URLSearchParams();
const sector = String(req.query.sector || '').trim();
if (sector && sector !== 'All') {
if (!ALLOWED_SCREENER_SECTORS.has(sector)) {
return res.status(400).json({ error: 'Unsupported sector filter' });
}
qs.set('sector', sector);
}
if (req.query.marketCapMoreThan) qs.set('marketCapMoreThan', String(req.query.marketCapMoreThan));
if (req.query.marketCapLessThan) qs.set('marketCapLessThan', String(req.query.marketCapLessThan));
if (req.query.betaMoreThan) qs.set('betaMoreThan', String(req.query.betaMoreThan));
if (req.query.betaLessThan) qs.set('betaLessThan', String(req.query.betaLessThan));
qs.set('limit', String(Math.min(100, Number(req.query.limit) || 50)));
qs.set('apikey', apiKey);
qs.set('isEtf', 'false');
const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`;
const data = await fetchFmpJson(url) as any;
res.json({ results: Array.isArray(data) ? data : [] });
} catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP screener fetch failed' });
}
res.status(500).json({ error: error.message });
}
});
}
private setupSocketHandlers() {
// ------------------------------------------------------------------
// Shared auth middleware factory
// ------------------------------------------------------------------
const makeAuthMiddleware = (namespaceLabel: string, requireAdminRole = false) =>
async (socket: Socket, next: (err?: Error) => void) => {
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;
}
const isAdmin = await isTradingAdmin(userId, role);
if (requireAdminRole && !isAdmin) {
next(new Error('Forbidden: admin role required'));
return;
}
socket.data.userId = userId;
socket.data.authRole = role;
socket.data.isAdmin = isAdmin;
socket.data.namespace = namespaceLabel;
next();
};
// ------------------------------------------------------------------
// Shared connection handler factory
// ------------------------------------------------------------------
const makeConnectionHandler = (namespaceLabel: string) => (socket: Socket) => {
const userId = String(socket.data.userId || '').trim();
const isAdmin = !!socket.data.isAdmin;
logger.info(`[API][${namespaceLabel}] Client connected: ${socket.id} (user: ${userId || 'unknown'}, admin: ${isAdmin})`);
if (userId) {
this.trackSocket(userId, socket);
const scopedState = this.getScopedState(userId, 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][${namespaceLabel}] Client disconnected: ${socket.id}`);
});
};
// ------------------------------------------------------------------
// Root namespace — backward-compatible (all authenticated users)
// ------------------------------------------------------------------
this.io.use(makeAuthMiddleware('root'));
this.io.on('connection', makeConnectionHandler('root'));
// ------------------------------------------------------------------
// /trading namespace — explicit user-facing namespace
// ------------------------------------------------------------------
const tradingNs = this.io.of(SOCKET_NAMESPACES.TRADING);
tradingNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.TRADING));
tradingNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.TRADING));
// ------------------------------------------------------------------
// /admin namespace — admin-only; non-admins are rejected at connect
// ------------------------------------------------------------------
const adminNs = this.io.of(SOCKET_NAMESPACES.ADMIN);
adminNs.use(makeAuthMiddleware(SOCKET_NAMESPACES.ADMIN, true));
adminNs.on('connection', makeConnectionHandler(SOCKET_NAMESPACES.ADMIN));
}
private startServer() {
this.httpServer.listen(this.port, () => {
logger.info(`[API] Server running on port ${this.port}`);
});
}
public updateSymbol(symbol: string, data: Partial<BotState['symbols'][string]>) {
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<BotState['settings']>) {
this.state.settings = { ...this.state.settings, ...settings };
this.broadcastSettingsUpdate();
this.saveState();
}
public updateRuntimeHealth(update: Partial<RuntimeHealth>) {
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<void> {
if (!this.httpServer.listening) {
return;
}
this.io.close();
await new Promise<void>((resolve, reject) => {
this.httpServer.close((err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
}