Web:
- runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle)
- tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*)
- useWebSocket: deriveSocketParams() — correctly splits origin + socket path for
Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path))
- App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab
- AdminTab: remove duplicate useWebSocket; accept socket as prop
- SignalsTab: connection-aware empty state message
- backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset
- EntryForm: NaN guard before live trade execution
- MarketplaceTab: null-safety on symbols.rules access
- Tests: pass socket prop to AdminTab; update empty state assertion
Mobile:
- TradingDataProvider: same deriveSocketParams fix — EXPO_PUBLIC_SOCKET_PATH
overrides auto-derived path from tradingApiUrl
- strategies: replace mock data with real GET /api/profiles + PATCH active toggle
- chat: wire to real POST /api/chat; remove hardcoded mock reply
- marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles
- settings: sign-out confirmation dialog; execution mode read-only hint;
version from expo-constants instead of hardcoded v2.3
- positions/history: empty state UI when no data
- CustomTabBar: always show tab labels (not only when focused)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
465 lines
14 KiB
TypeScript
465 lines
14 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';
|
|
import { createRequestId } from '../../shared/request-id.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);
|
|
|
|
function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } {
|
|
const envPath = process.env.EXPO_PUBLIC_SOCKET_PATH?.trim();
|
|
try {
|
|
const parsed = new URL(tradingApiUrl);
|
|
const prefix = parsed.pathname.replace(/\/api\/?$/, '');
|
|
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' };
|
|
}
|
|
}
|
|
|
|
const { socketOrigin: tradingSocketOrigin, socketPath: tradingSocketPath } =
|
|
deriveSocketParams(mobileRuntime.tradingApiUrl);
|
|
|
|
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}`,
|
|
'x-request-id': createRequestId('mobile-state'),
|
|
},
|
|
});
|
|
|
|
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(tradingSocketOrigin, buildTradingSocketOptions(accessToken, tradingSocketPath));
|
|
|
|
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}`,
|
|
'x-request-id': createRequestId('mobile-control'),
|
|
},
|
|
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;
|
|
}
|