Files
trade-frontend/apps/visualizer/src/features/bot/useBotObserver.ts

291 lines
8.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useInterval } from '../../app/hooks/useInterval';
export type BotConfigSummary = {
id: string;
name: string;
market_name: string;
market_type: string;
mode: string;
kill_switch: boolean;
updated_at: string;
};
export type BotObserverRuntimeState = {
service?: string;
version?: string;
appVersion?: string;
observe_only?: boolean;
market_name?: string;
market_type?: string;
mode?: string;
kill_switch?: boolean;
status?: string;
strategy_type?: string;
loop_ms?: number;
last_snapshot?: {
query_latency_ms?: number | null;
data_age_ms?: number | null;
candles_count?: number | null;
stats_age_ms?: number | null;
depth_age_ms?: number | null;
buy_slippage_age_ms?: number | null;
sell_slippage_age_ms?: number | null;
} | null;
last_features?: {
mark_price?: number | null;
oracle_price?: number | null;
mid_price?: number | null;
spread_bps?: number | null;
stats_imbalance?: number | null;
depth_bid_usd?: number | null;
depth_ask_usd?: number | null;
depth_imbalance?: number | null;
buy_slippage_bps?: number | null;
sell_slippage_bps?: number | null;
mark_vs_oracle_bps?: number | null;
mom_3s?: number | null;
mom_10s?: number | null;
mom_30s?: number | null;
vol_30s?: number | null;
} | null;
last_gates?: {
fresh?: boolean;
spread_ok?: boolean;
slippage_ok?: boolean;
depth_ok?: boolean;
has_candles?: boolean;
all?: boolean;
} | null;
last_decision?: {
side?: string;
confidence?: number | null;
long_score?: number | null;
short_score?: number | null;
target_notional_usd?: number | null;
horizon_s?: number | null;
skip_reason?: string | null;
} | null;
counters?: {
loops?: number;
decisions?: number;
skips?: number;
errors?: number;
} | null;
inactive_reason?: string;
error?: string;
trade_requested_but_observer_only?: boolean;
};
export type BotStateRow = {
bot_id: string;
last_heartbeat_at: string | null;
last_action_at: string | null;
last_error: string | null;
state: BotObserverRuntimeState | null;
updated_at: string;
};
export type BotEventRow = {
id: number;
ts: string;
type: string;
payload?: Record<string, unknown> | null;
};
export type BotObserverData = {
bot: BotConfigSummary | null;
resolvedBy: string | null;
state: BotStateRow | null;
events: BotEventRow[];
loading: boolean;
error: string | null;
warning: string | null;
lastUpdatedAt: string | null;
};
function envString(name: string): string | undefined {
const value = (import.meta as any).env?.[name];
if (value == null) return undefined;
const text = String(value).trim();
return text || undefined;
}
function getApiBase(): string {
return envString('VITE_API_URL') || '/api';
}
function buildApiUrl(pathname: string): string {
const base = new URL(getApiBase(), window.location.origin);
const basePath = base.pathname && base.pathname !== '/' ? base.pathname.replace(/\/$/, '') : '';
const [pathOnly, searchOnly = ''] = String(pathname || '').split('?');
base.pathname = `${basePath}${pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`}`;
base.search = searchOnly ? `?${searchOnly}` : '';
base.hash = '';
return base.toString();
}
async function apiRequest<T>(pathname: string): Promise<T> {
const res = await fetch(buildApiUrl(pathname), {
cache: 'no-store',
credentials: 'same-origin',
});
const text = await res.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
throw new Error(`Invalid API JSON for ${pathname}`);
}
if (!res.ok) throw new Error(json?.error || `API HTTP ${res.status}`);
return json as T;
}
function preferredBotId(): string | undefined {
return envString('VITE_BOT_ID');
}
function preferredBotName(): string | undefined {
return envString('VITE_BOT_NAME');
}
function resolveBot(bots: BotConfigSummary[], marketName: string): { bot: BotConfigSummary | null; by: string | null } {
const configuredId = preferredBotId();
if (configuredId) {
const match = bots.find((entry) => entry.id === configuredId);
if (match) return { bot: match, by: 'env:id' };
}
const configuredName = preferredBotName();
if (configuredName) {
const match = bots.find((entry) => entry.name === configuredName);
if (match) return { bot: match, by: 'env:name' };
}
const match = bots.find((entry) => String(entry.market_name || '').trim().toUpperCase() === marketName.trim().toUpperCase());
if (match) return { bot: match, by: 'market' };
return { bot: null, by: null };
}
export function useBotObserver(marketName: string): BotObserverData {
const mountedRef = useRef(true);
const resolveInFlightRef = useRef(false);
const detailsInFlightRef = useRef(false);
const [bot, setBot] = useState<BotConfigSummary | null>(null);
const [resolvedBy, setResolvedBy] = useState<string | null>(null);
const [state, setState] = useState<BotStateRow | null>(null);
const [events, setEvents] = useState<BotEventRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
async function refreshBotResolution() {
if (resolveInFlightRef.current) return;
resolveInFlightRef.current = true;
try {
const json = await apiRequest<{ ok?: boolean; bots?: BotConfigSummary[]; error?: string; warning?: string }>(
'/v1/bots?limit=100'
);
const bots = Array.isArray(json?.bots) ? json.bots : [];
const resolved = resolveBot(bots, marketName);
if (!mountedRef.current) return;
setBot((current) => {
if (current?.id === resolved.bot?.id) return current;
return resolved.bot;
});
setResolvedBy(resolved.by);
if (!resolved.bot) {
setState(null);
setEvents([]);
setLoading(false);
}
setWarning(typeof json?.warning === 'string' && json.warning ? json.warning : null);
setError(null);
} catch (err) {
if (!mountedRef.current) return;
setError(String((err as Error)?.message || err));
setLoading(false);
} finally {
resolveInFlightRef.current = false;
}
}
async function refreshBotDetails(botId: string) {
if (detailsInFlightRef.current) return;
detailsInFlightRef.current = true;
try {
const [stateJson, eventsJson] = await Promise.all([
apiRequest<{ ok?: boolean; state?: BotStateRow | null; error?: string; warning?: string }>(
`/v1/bots/${botId}/state`
),
apiRequest<{ ok?: boolean; events?: BotEventRow[]; error?: string; warning?: string }>(
`/v1/bots/${botId}/events?limit=24`
),
]);
if (!mountedRef.current) return;
setState(stateJson?.state || null);
setEvents(Array.isArray(eventsJson?.events) ? eventsJson.events : []);
setLastUpdatedAt(new Date().toISOString());
setWarning(
(typeof stateJson?.warning === 'string' && stateJson.warning) ||
(typeof eventsJson?.warning === 'string' && eventsJson.warning) ||
null
);
setError(null);
setLoading(false);
} catch (err) {
if (!mountedRef.current) return;
setError(String((err as Error)?.message || err));
setLoading(false);
} finally {
detailsInFlightRef.current = false;
}
}
useEffect(() => {
setLoading(true);
setBot(null);
setResolvedBy(null);
setState(null);
setEvents([]);
setError(null);
setWarning(null);
setLastUpdatedAt(null);
void refreshBotResolution();
}, [marketName]);
useEffect(() => {
if (!bot?.id) return;
void refreshBotDetails(bot.id);
}, [bot?.id]);
useInterval(() => {
void refreshBotResolution();
}, 5000);
useInterval(() => {
if (!bot?.id) return;
void refreshBotDetails(bot.id);
}, bot?.id ? 1000 : null);
return {
bot,
resolvedBy,
state,
events,
loading,
error,
warning,
lastUpdatedAt,
};
}