277 lines
7.6 KiB
TypeScript
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;
|
|
}
|