learning_ai_invt_trdg/mobile/providers/TradingDataProvider.tsx

277 lines
7.6 KiB
TypeScript

import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { mobileRuntime } from '@/lib/runtime';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
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;
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 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 } = 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 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) {
throw new Error(`Trading state request failed (${response.status})`);
}
const data = (await response.json()) as BotState;
setBotState(data);
setConnected(true);
setError(null);
} catch (fetchError) {
setConnected(false);
setError(fetchError instanceof Error ? fetchError.message : 'Failed to load trading state');
} finally {
setLoading(false);
}
}, [accessToken, user]);
useEffect(() => {
void fetchState();
if (!accessToken || !user) {
return;
}
const interval = setInterval(() => {
void fetchState();
}, 15000);
return () => clearInterval(interval);
}, [accessToken, user, fetchState]);
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) {
return { error: body.error || `Request failed (${response.status})` };
}
await fetchState();
return {};
} catch (actionError) {
return { error: actionError instanceof Error ? actionError.message : 'Trading action failed' };
}
},
[accessToken, fetchState]
);
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 value = useMemo<TradingDataContextValue>(
() => ({
botState,
loading,
error,
connected,
refresh: fetchState,
pauseTrading: (reason?: string) => postTradingAction('/internal/trading/pause', reason),
resumeTrading: (reason?: string) => postTradingAction('/internal/trading/resume', reason),
portfolio,
marketTicker,
}),
[botState, loading, error, connected, 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;
}