learning_ai_invt_trdg/web/src/hooks/useWebSocket.ts

436 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { buildTradingSocketOptions } from '../../../shared/realtime.js';
import { getPlatformAccessToken } from '../lib/authSession';
export interface TradingControlSnapshot {
mode: 'RUNNING' | 'PAUSED';
lastChangedBy: string;
lastChangedAt: number;
reason?: string;
}
export interface HealthSnapshot {
tradingLoopHealthy: boolean;
tradingLoopLastRun: number | null;
monitorLoopHealthy: boolean;
monitorLoopLastRun: number | null;
orderSyncHealthy: boolean;
orderSyncLastRun: number | null;
lockContentionCount: number;
reconciliationLoopHealthy: boolean;
reconciliationLoopLastRun: number | null;
reconciliationMismatchCount: number;
reconciliationMissingFromExchange: number;
reconciliationMissingInDb: number;
reconciliationNoGoTrades: number;
reconciliationParityMismatchTrades?: number;
reconciliationParityQuarantinedTrades?: number;
reconciliationParityAutoClosedTrades?: number;
reconciliationParityMaxMismatchNotionalUsd?: number;
reconciliationParityTotalMismatchNotionalUsd?: number;
reconciliationIntegrityWatchdogTriggered: boolean;
reconciliationLockContentionCount: number;
tradingControl: TradingControlSnapshot;
}
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;
profileId?: 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;
}>;
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;
profileId?: string;
profileName?: string;
tradeId?: string;
}>;
orders: Array<{
id: string;
symbol: string;
type: string;
side: string;
qty: number;
price: number;
status: string;
timestamp: number;
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;
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?: {
buying_power: number;
cash: number;
currency: string;
timestamp: number;
} | null;
orderFailures?: Array<{
profileId?: string;
userId?: string;
symbol: string;
side: 'BUY' | 'SELL';
qty: number;
reason: string;
tradeId?: string;
subTag?: string;
timestamp: number;
}>;
operationalEvents?: Array<{
id: string;
type: string;
severity: string;
message: string;
profileId?: string;
userId?: string;
symbol?: string;
setupId?: string;
tradeId?: string;
orderId?: string;
timestamp: number;
}>;
}
function isOperationalEventRecord(value: unknown): value is NonNullable<BotState['operationalEvents']>[number] {
if (!value || typeof value !== 'object') return false;
const event = value as Record<string, unknown>;
return typeof event.id === 'string'
&& typeof event.type === 'string'
&& typeof event.severity === 'string'
&& typeof event.message === 'string'
&& typeof event.timestamp === 'number';
}
export const DEFAULT_BOT_STATE: BotState = {
symbols: {},
positions: [],
alerts: [],
orders: [],
history: [],
settings: {
executionMode: 'Alerts',
riskPerTrade: 0.01,
totalCapital: 1000,
maxOpenTrades: 3,
isAlgoEnabled: false,
enabledRules: []
},
health: {
tradingLoopHealthy: true,
tradingLoopLastRun: null,
monitorLoopHealthy: true,
monitorLoopLastRun: null,
orderSyncHealthy: true,
orderSyncLastRun: null,
lockContentionCount: 0,
reconciliationLoopHealthy: true,
reconciliationLoopLastRun: null,
reconciliationMismatchCount: 0,
reconciliationMissingFromExchange: 0,
reconciliationMissingInDb: 0,
reconciliationNoGoTrades: 0,
reconciliationIntegrityWatchdogTriggered: false,
reconciliationLockContentionCount: 0,
tradingControl: {
mode: 'RUNNING',
lastChangedBy: 'system',
lastChangedAt: Date.now()
}
},
uptime: 0,
accountSnapshot: null,
orderFailures: [],
operationalEvents: []
};
export const applySymbolUpdate = (
prev: BotState,
symbol: string,
data: BotState['symbols'][string]
): BotState => ({
...prev,
symbols: {
...prev.symbols,
[symbol]: data
}
});
export const appendAlertUpdate = (prev: BotState, alert: BotState['alerts'][0]): BotState => ({
...prev,
alerts: [...prev.alerts, alert]
});
export const replacePositionsUpdate = (prev: BotState, positions: BotState['positions']): BotState => ({
...prev,
positions
});
export const replaceOrdersUpdate = (prev: BotState, orders: BotState['orders']): BotState => ({
...prev,
orders
});
export const appendHistoryUpdate = (prev: BotState, trade: BotState['history'][0]): BotState => ({
...prev,
history: [...prev.history, trade]
});
export const replaceSettingsUpdate = (prev: BotState, settings: BotState['settings']): BotState => ({
...prev,
settings
});
/**
* Derive the Socket.IO server origin and path from a trading API URL.
* - Production: https://api.bytelyst.com/invttrdg/api
* → origin: https://api.bytelyst.com, path: /invttrdg/socket.io
* - Local dev: http://localhost:4018/api
* → origin: http://localhost:4018, path: /socket.io (default)
* VITE_SOCKET_PATH overrides the derived path when explicitly set.
*/
function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } {
const envPath = (import.meta.env.VITE_SOCKET_PATH as string | undefined)?.trim();
try {
const parsed = new URL(tradingApiUrl);
const prefix = parsed.pathname.replace(/\/api\/?$/, ''); // e.g. '/invttrdg'
const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io');
return { socketOrigin: parsed.origin, socketPath };
} catch {
return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' };
}
}
export const useWebSocket = (url: string) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [botState, setBotState] = useState<BotState>(DEFAULT_BOT_STATE);
const [connected, setConnected] = useState(false);
const EVENT_BUFFER_LIMIT = 2000;
useEffect(() => {
let isCancelled = false;
let newSocket: Socket | null = null;
const connectSocket = async () => {
const { socketOrigin, socketPath } = deriveSocketParams(url);
console.log('🔌 Attempting to connect to:', socketOrigin, 'path:', socketPath);
const token = await getPlatformAccessToken().catch(() => null);
if (!token) {
console.warn('Socket connection skipped: missing authenticated session token');
setConnected(false);
return;
}
const socketOptions = buildTradingSocketOptions(token, socketPath);
newSocket = io(socketOrigin, socketOptions);
newSocket.on('connect', () => {
console.log('✅ Connected to bot');
setConnected(true);
});
newSocket.on('disconnect', () => {
console.log('❌ Disconnected from bot');
setConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('🚨 Connection error:', error.message);
});
newSocket.on('state', (state: BotState) => {
setBotState(prev => ({
...prev,
...state,
health: state.health || prev.health
}));
});
newSocket.on('health_update', (health: HealthSnapshot) => {
setBotState(prev => ({
...prev,
health
}));
});
newSocket.on('symbol_update', ({ symbol, data }: any) => {
setBotState(prev => applySymbolUpdate(prev, symbol, data));
});
newSocket.on('new_alert', (alert: BotState['alerts'][0]) => {
setBotState(prev => appendAlertUpdate(prev, alert));
});
newSocket.on('positions_update', (positions: BotState['positions']) => {
setBotState(prev => replacePositionsUpdate(prev, positions));
});
newSocket.on('orders_update', (orders: BotState['orders']) => {
setBotState(prev => replaceOrdersUpdate(prev, orders));
});
newSocket.on('history_update', (trade: BotState['history'][0]) => {
setBotState(prev => appendHistoryUpdate(prev, trade));
});
newSocket.on('settings_update', (settings: BotState['settings']) => {
setBotState(prev => replaceSettingsUpdate(prev, settings));
});
newSocket.on('account_snapshot', (snapshot: any) => {
setBotState(prev => ({
...prev,
accountSnapshot: snapshot
}));
});
newSocket.on('order_failure', (failure: any) => {
const enrichedFailure = {
...failure,
timestamp: failure.timestamp || Date.now()
};
setBotState(prev => ({
...prev,
orderFailures: [enrichedFailure, ...(prev.orderFailures || [])].slice(0, EVENT_BUFFER_LIMIT)
}));
});
newSocket.on('operational_event', (event: any) => {
if (!isOperationalEventRecord(event)) {
console.warn('Ignoring malformed operational_event payload', event);
return;
}
setBotState(prev => ({
...prev,
operationalEvents: [event, ...(prev.operationalEvents || [])].slice(0, EVENT_BUFFER_LIMIT)
}));
});
newSocket.on('operational_event_cleared', () => {
setBotState(prev => ({
...prev,
operationalEvents: []
}));
});
if (!isCancelled) {
setSocket(newSocket);
} else {
newSocket.close();
}
};
connectSocket().catch((error) => {
console.error('🚨 Failed to initialize socket:', error);
setConnected(false);
});
return () => {
isCancelled = true;
if (newSocket) {
newSocket.close();
}
};
}, [url]);
return { socket, botState, connected };
};