436 lines
14 KiB
TypeScript
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 };
|
|
};
|