WebSocket Events
The system uses WebSocket for real-time updates from backend to frontend. The WebSocket endpoint subscribes to Redis pub/sub channels and forwards events to connected dashboard clients.
Endpoint
URL: /ws Protocol: WebSocket (ws:// for development, wss:// for production) Proxy: Vite dev server proxies /ws to ws://localhost:8000
Connection Flow
- Client connects to
/ws - Server accepts connection and adds to
ConnectionManager - Server subscribes to Redis pub/sub channels
- Client sends
pingevery 30 seconds for keepalive - Server broadcasts Redis messages to all connected clients
- On disconnect, client auto-reconnects after 3 seconds
Client-side (React)
useWebSocket Hook
The useWebSocket hook in web/src/hooks/useWebSocket.ts manages the WebSocket connection:
import { useWebSocket } from '../hooks/useWebSocket';
function MyComponent() {
const { lastMessage, connected } = useWebSocket();
useEffect(() => {
if (lastMessage?.channel === 'portfolio:update') {
console.log('Portfolio updated:', lastMessage.data);
}
}, [lastMessage]);
return <div>{connected ? 'Live' : 'Reconnecting...'}</div>;
}
Return values:
lastMessage: Most recent WebSocket message (typeWSMessage | null)connected: Connection status (boolean)
Behavior:
- Auto-connects on mount
- Sends
{"type": "ping"}every 30 seconds - Ignores
{"type": "pong"}responses from server - Auto-reconnects on close/error (3-second delay)
- Cleans up on unmount
Server-side (FastAPI)
WebSocket Route
Defined in src/api/routes/websocket.py:
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
subscriber_task = asyncio.create_task(redis_subscriber())
try:
while True:
data = await websocket.receive_text()
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
manager.disconnect(websocket)
finally:
subscriber_task.cancel()
Redis Subscriber
The redis_subscriber() coroutine subscribes to Redis pub/sub channels and broadcasts messages:
async def redis_subscriber():
redis = await get_redis()
pubsub = redis.pubsub()
await pubsub.subscribe(*CHANNELS)
async for message in pubsub.listen():
if message["type"] == "message":
data = json.loads(message["data"])
event = {
"channel": message["channel"],
"data": data,
}
await manager.broadcast(event)
Event Types
All events follow this structure:
interface WSMessage {
channel: string; // Redis pub/sub channel
data: any; // Event payload (varies by channel)
}
portfolio:update
Purpose: Portfolio value or position changes Frequency: On position entry/exit, market data update Payload:
{
channel: "portfolio:update",
data: {
total_equity: 25000.00,
cash: 5000.00,
positions_value: 20000.00,
daily_pnl: 250.00,
daily_pnl_pct: 1.02,
total_pnl: 1500.00,
total_pnl_pct: 6.38,
max_drawdown_pct: -2.5,
positions: [...],
sector_exposure: {...}
}
}
trade:executed
Purpose: Trade fill confirmation Frequency: On order fill Payload:
{
channel: "trade:executed",
data: {
id: "trade-123",
symbol: "AAPL",
side: "BUY",
quantity: 10,
entry_price: 178.42,
entry_time: "2026-03-12T14:30:00Z",
strategy_name: "mean_reversion"
}
}
trade:pending_approval
Purpose: Trade awaiting manual approval (MANUAL_APPROVAL autonomy mode) Frequency: When strategy generates signal in manual mode Payload:
{
channel: "trade:pending_approval",
data: {
symbol: "AAPL",
side: "BUY",
quantity: 10,
signal_strength: 0.82,
strategy_name: "ml_signal",
risk_assessment: {
position_limit_ok: true,
portfolio_limit_ok: true,
pdt_ok: true
},
requires_approval_by: "2026-03-12T14:45:00Z"
}
}
Dashboard action: Show modal with Approve/Reject buttons.
order:status
Purpose: Order status change Frequency: On submission, fill, cancellation, rejection Payload:
{
channel: "order:status",
data: {
id: "order-456",
broker_order_id: "12345678",
symbol: "MSFT",
status: "FILLED", // PENDING, SUBMITTED, FILLED, CANCELLED, REJECTED
filled_at: "2026-03-12T14:32:11Z",
filled_price: 415.67,
filled_quantity: 5
}
}
signal:generated
Purpose: New trading signal from strategy Frequency: When strategy generates a signal Payload:
{
channel: "signal:generated",
data: {
symbol: "TSLA",
direction: "BUY", // BUY, SELL, HOLD
strength: 0.75, // 0.0 to 1.0
strategy_name: "momentum_crossover",
timestamp: "2026-03-12T14:35:00Z",
metadata: {
indicators: {
rsi: 42.5,
macd: 2.3
}
}
}
}
risk:alert
Purpose: Risk warning (limit breach, high volatility, etc.) Frequency: When risk check triggers a warning Payload:
{
channel: "risk:alert",
data: {
type: "position_limit", // position_limit, sector_limit, drawdown, etc.
severity: "warning", // info, warning, critical
reason: "AAPL position size 6.2% exceeds 5% limit",
symbol: "AAPL",
current_value: 6.2,
limit_value: 5.0,
timestamp: "2026-03-12T14:40:00Z"
}
}
Dashboard action: Show toast notification with amber/red color based on severity.
risk:circuit_breaker
Purpose: Circuit breaker state change Frequency: On activation/deactivation Payload:
{
channel: "risk:circuit_breaker",
data: {
breaker: "vix_spike", // vix_spike, stale_data, reconciliation_failed
action: "activated", // activated, deactivated
reason: "VIX crossed threshold: 37.2 > 35",
timestamp: "2026-03-12T14:50:00Z"
}
}
Dashboard action: Show banner at top of screen. Disable trading UI when active.
risk:kill_switch
Purpose: Kill switch activation/deactivation Frequency: On kill switch state change Payload:
{
channel: "risk:kill_switch",
data: {
action: "activate", // activate, deactivate
reason: "Manual override - unusual market conditions",
operator: "human", // human, system
timestamp: "2026-03-12T15:00:00Z"
}
}
Dashboard action: Show full-screen modal requiring typed confirmation (“KILL” or “RESUME”).
system:feed:disconnected
Purpose: Market data feed disconnection Frequency: When IBKR feed disconnects Payload:
{
channel: "system:feed:disconnected",
data: {
timestamp: "2026-03-12T16:00:00Z",
reason: "IB Gateway connection closed (market close)"
}
}
Dashboard action: Show “Feed Disconnected” indicator in header.
system:feed:reconnected
Purpose: Market data feed reconnection Frequency: When IBKR feed reconnects Payload:
{
channel: "system:feed:reconnected",
data: {
timestamp: "2026-03-13T09:30:00Z",
symbol_count: 503
}
}
Dashboard action: Show “Feed Connected” indicator in header.
Keepalive (Ping/Pong)
Client → Server:
{"type": "ping"}
Server → Client:
{"type": "pong"}
Frequency: Client sends ping every 30 seconds. Purpose: Prevent idle connection timeout, detect broken connections.
The useWebSocket hook filters out pong messages so they don’t trigger re-renders.
Testing WebSocket Events
Publish test event via Redis CLI
# Connect to Redis container
docker exec -it alpha-oracle-redis-1 redis-cli
# Publish test portfolio update
PUBLISH portfolio:update '{"total_equity": 26000, "cash": 6000, "positions_value": 20000}'
# Publish test signal
PUBLISH signal:generated '{"symbol": "AAPL", "direction": "BUY", "strength": 0.8, "strategy_name": "test"}'
The dashboard should receive and display the event immediately.
WebSocket debugging in browser
// Browser console
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onmessage = (event) => console.log(JSON.parse(event.data));
ws.send(JSON.stringify({ type: 'ping' }));
Error Handling
Connection failure:
useWebSockethook retries after 3 seconds- Dashboard shows “Disconnected” status
- User actions queue locally until reconnection (if implemented)
Malformed message:
- Server catches
json.JSONDecodeErrorand wraps in{"raw": "..."}object - Client ignores or logs malformed messages
Broadcast failure:
- If a client’s
send_json()throws, it’s removed fromactive_connections - Other clients continue to receive events