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>
3255 lines
139 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
}
|
|
}
|