learning_ai_invt_trdg/mobile/providers/TradingDataProvider.tsx

448 lines
13 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { io, type Socket } from 'socket.io-client';
import { mobileRuntime } from '@/lib/runtime';
import { mobileTelemetry, trackMobileError } from '@/lib/telemetry';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
import { buildTradingSocketOptions, isUnauthorizedSocketError } from '../../shared/realtime.js';
type HealthSnapshot = {
tradingControl?: {
mode: 'RUNNING' | 'PAUSED';
lastChangedBy: string;
lastChangedAt: number;
reason?: string;
};
};
type BotState = {
symbols: Record<string, {
price: number;
change24h: number;
signal?: string;
activePosition?: {
side: 'BUY' | 'SELL';
entryPrice: number;
size: number;
stopLoss: number;
takeProfit: number;
unrealizedPnl?: number;
unrealizedPnlPercent?: number;
marketValue?: number;
profileId?: string;
profileName?: string;
tradeId?: string;
} | null;
priceHistory?: Array<{ timestamp: number; price: number }>;
profileSignals?: Record<string, {
signal: string;
profileName?: string;
}>;
}>;
alerts: Array<{
timestamp: number;
type: 'signal' | 'pulse' | 'error' | 'info';
symbol: string;
message: 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;
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;
action?: 'ENTRY' | 'EXIT';
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;
profileName?: string;
source?: 'BOT' | 'MANUAL';
}>;
settings: {
executionMode: string;
riskPerTrade: number;
totalCapital: number;
maxOpenTrades: number;
isAlgoEnabled: boolean;
};
uptime: number;
health?: HealthSnapshot;
};
export interface TradingPortfolioSummary {
netPnl: number;
netPnlPercent: number;
totalCapital: number;
deployed: number;
available: number;
utilization: number;
realizedPnl: number;
unrealizedPnl: number;
}
interface TradingDataContextValue {
botState: BotState | null;
loading: boolean;
error: string | null;
connected: boolean;
connectionState: 'live' | 'degraded' | 'offline';
lastUpdatedAt: number | null;
refresh: () => Promise<void>;
pauseTrading: (reason?: string) => Promise<{ error?: string }>;
resumeTrading: (reason?: string) => Promise<{ error?: string }>;
portfolio: TradingPortfolioSummary;
marketTicker: Array<{ symbol: string; price: number; change: number }>;
}
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
const tradingSocketUrl = mobileRuntime.tradingApiUrl.replace(/\/api$/, '');
const EMPTY_STATE: TradingPortfolioSummary = {
netPnl: 0,
netPnlPercent: 0,
totalCapital: 0,
deployed: 0,
available: 0,
utilization: 0,
realizedPnl: 0,
unrealizedPnl: 0,
};
export function TradingDataProvider({ children }: { children: ReactNode }) {
const { accessToken, user, invalidateSession } = useMobileAuth();
const [botState, setBotState] = useState<BotState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const [statusClock, setStatusClock] = useState(() => Date.now());
const markStateSynced = useCallback(() => {
setLastUpdatedAt(Date.now());
}, []);
const fetchState = useCallback(async () => {
if (!accessToken || !user) {
setBotState(null);
setConnected(false);
setLoading(false);
return;
}
setLoading(true);
try {
const response = await fetch(`${mobileRuntime.tradingApiUrl}/state`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await invalidateSession(`Trading session expired (${response.status})`);
return;
}
throw new Error(`Trading state request failed (${response.status})`);
}
const data = (await response.json()) as BotState;
setBotState(data);
setConnected(true);
setError(null);
markStateSynced();
} catch (fetchError) {
setConnected(false);
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state');
trackMobileError('trading_data', 'state_fetch_failed', fetchError);
} finally {
setLoading(false);
}
}, [accessToken, user, invalidateSession, markStateSynced]);
useEffect(() => {
void fetchState();
if (!accessToken || !user) {
return;
}
const interval = setInterval(() => {
void fetchState();
}, 60000);
return () => clearInterval(interval);
}, [accessToken, user, fetchState]);
useEffect(() => {
const interval = setInterval(() => {
setStatusClock(Date.now());
}, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!accessToken || !user) {
return;
}
let socket: Socket | null = null;
const mergePartialState = (nextState: BotState) => {
setBotState((prev) => ({
...(prev || nextState),
...nextState,
health: nextState.health || prev?.health,
}));
};
socket = io(tradingSocketUrl, buildTradingSocketOptions(accessToken));
socket.on('connect', () => {
setConnected(true);
setError(null);
mobileTelemetry.trackEvent('info', 'realtime', 'socket_connected');
});
socket.on('disconnect', () => {
setConnected(false);
mobileTelemetry.trackEvent('warn', 'realtime', 'socket_disconnected');
});
socket.on('connect_error', (socketError) => {
setError(socketError.message);
trackMobileError('realtime', 'socket_connect_failed', socketError);
if (isUnauthorizedSocketError(socketError.message)) {
void invalidateSession(socketError.message);
}
});
socket.on('state', (nextState: BotState) => {
mergePartialState(nextState);
markStateSynced();
});
socket.on('health_update', (health: HealthSnapshot) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, health } : prev));
});
socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => {
markStateSynced();
setBotState((prev) => {
if (!prev) return prev;
return {
...prev,
symbols: {
...prev.symbols,
[symbol]: data,
},
};
});
});
socket.on('new_alert', (alert: BotState['alerts'][number]) => {
markStateSynced();
setBotState((prev) => {
if (!prev) return prev;
return {
...prev,
alerts: [alert, ...prev.alerts].slice(0, 25),
};
});
});
socket.on('positions_update', (positions: BotState['positions']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, positions } : prev));
});
socket.on('orders_update', (orders: BotState['orders']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, orders } : prev));
});
socket.on('history_update', (trade: BotState['history'][number]) => {
markStateSynced();
setBotState((prev) => {
if (!prev) return prev;
return {
...prev,
history: [trade, ...prev.history].slice(0, 100),
};
});
});
socket.on('settings_update', (settings: BotState['settings']) => {
markStateSynced();
setBotState((prev) => (prev ? { ...prev, settings } : prev));
});
socket.on('account_snapshot', () => {
void fetchState();
});
socket.on('order_failure', () => {
void fetchState();
});
socket.on('operational_event', () => {
void fetchState();
});
return () => {
socket?.close();
};
}, [accessToken, user, fetchState, invalidateSession, markStateSynced]);
const postTradingAction = useCallback(
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
if (!accessToken) {
return { error: 'Not authenticated' };
}
try {
const response = await fetch(`${mobileRuntime.tradingApiUrl.replace(/\/api$/, '')}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
reason: reason || 'Requested from mobile trading surface',
}),
});
const body = await response.json().catch(() => ({} as { error?: string }));
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await invalidateSession(body.error || `Trading control unauthorized (${response.status})`);
return { error: body.error || `Request failed (${response.status})` };
}
mobileTelemetry.trackEvent('warn', 'controls', 'trading_action_failed', {
message: body.error || `Request failed (${response.status})`,
tags: { path },
});
return { error: body.error || `Request failed (${response.status})` };
}
await fetchState();
mobileTelemetry.trackEvent('info', 'controls', 'trading_action_succeeded', {
tags: { path },
});
return {};
} catch (actionError) {
trackMobileError('controls', 'trading_action_failed', actionError, { path });
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
}
},
[accessToken, fetchState, invalidateSession]
);
const portfolio = useMemo<TradingPortfolioSummary>(() => {
if (!botState) {
return EMPTY_STATE;
}
const unrealizedPnl = botState.positions.reduce((sum, position) => sum + Number(position.unrealizedPnl || 0), 0);
const deployed = botState.positions.reduce((sum, position) => sum + Number(position.marketValue || 0), 0);
const realizedPnl = botState.history.reduce((sum, trade) => sum + Number(trade.pnl || 0), 0);
const totalCapital = Number(botState.settings.totalCapital || 0);
const available = Math.max(totalCapital - deployed, 0);
const netPnl = realizedPnl + unrealizedPnl;
const netPnlPercent = totalCapital > 0 ? (netPnl / totalCapital) * 100 : 0;
const utilization = totalCapital > 0 ? Math.min((deployed / totalCapital) * 100, 100) : 0;
return {
netPnl,
netPnlPercent,
totalCapital,
deployed,
available,
utilization,
realizedPnl,
unrealizedPnl,
};
}, [botState]);
const marketTicker = useMemo(
() =>
Object.entries(botState?.symbols || {}).map(([symbol, data]) => ({
symbol,
price: Number(data.price || 0),
change: Number(data.change24h || 0),
})),
[botState]
);
const connectionState = useMemo<'live' | 'degraded' | 'offline'>(() => {
if (!connected && !botState) {
return 'offline';
}
const staleMs = lastUpdatedAt ? statusClock - lastUpdatedAt : Number.POSITIVE_INFINITY;
if (connected && !error && staleMs < 90_000) {
return 'live';
}
if (botState || connected) {
return 'degraded';
}
return 'offline';
}, [connected, botState, error, lastUpdatedAt, statusClock]);
const value = useMemo<TradingDataContextValue>(
() => ({
botState,
loading,
error,
connected,
connectionState,
lastUpdatedAt,
refresh: fetchState,
pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason),
resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason),
portfolio,
marketTicker,
}),
[botState, loading, error, connected, connectionState, lastUpdatedAt, fetchState, postTradingAction, portfolio, marketTicker]
);
return <TradingDataContext.Provider value={value}>{children}</TradingDataContext.Provider>;
}
export function useTradingData() {
const context = useContext(TradingDataContext);
if (!context) {
throw new Error('useTradingData must be used within a TradingDataProvider');
}
return context;
}