diff --git a/src/ui/BasisApp.tsx b/src/ui/BasisApp.tsx index 28c253b..1af22ba 100644 --- a/src/ui/BasisApp.tsx +++ b/src/ui/BasisApp.tsx @@ -1,59 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; +import React from "react"; +import { Box, Text } from "ink"; import { basisConfig } from "../config"; -import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; +import type { BasisArbSnapshot } from "../strategy/basis-arb-engine"; import { formatNumber } from "../utils/format"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface BasisAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function BasisApp({ onExit }: BasisAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - if (!isBasisSupportedExchangeId(exchangeId)) { - setError(new Error(t("basis.onlyAster"))); - return; - } - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol }); - const engine = new BasisArbEngine(basisConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: BasisArbSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("basis", { + onExit + }); if (error) { return ( diff --git a/src/ui/GridApp.tsx b/src/ui/GridApp.tsx index 32bb9a6..467488e 100644 --- a/src/ui/GridApp.tsx +++ b/src/ui/GridApp.tsx @@ -1,61 +1,26 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; +import React from "react"; +import { Box, Text } from "ink"; import { gridConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; +import type { GridEngineSnapshot } from "../strategy/grid-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface GridAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function GridApp({ onExit }: GridAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: gridConfig.symbol }); - const engine = new GridEngine(gridConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: GridEngineSnapshot) => { - setSnapshot({ - ...next, - desiredOrders: [...next.desiredOrders], - gridLines: [...next.gridLines], - tradeLog: [...next.tradeLog], - }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("grid", { + onExit, + cloneSnapshot: (next) => ({ + ...next, + desiredOrders: [...next.desiredOrders], + gridLines: [...next.gridLines], + tradeLog: [...next.tradeLog], + }), + }); if (error) { return ( diff --git a/src/ui/GuardianApp.tsx b/src/ui/GuardianApp.tsx index a420f11..199e80d 100644 --- a/src/ui/GuardianApp.tsx +++ b/src/ui/GuardianApp.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { tradingConfig } from "../config"; -import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { formatNumber } from "../utils/format"; import { DataTable, type TableColumn } from "./components/DataTable"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface GuardianAppProps { @@ -13,45 +11,10 @@ interface GuardianAppProps { } const READY_MESSAGE = t("guardian.readyMessage"); -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function GuardianApp({ onExit }: GuardianAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol }); - const engine = new GuardianEngine(tradingConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: GuardianEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("guardian", { + onExit + }); if (error) { return ( diff --git a/src/ui/LiquidityMakerApp.tsx b/src/ui/LiquidityMakerApp.tsx index 4c3a558..a288a38 100644 --- a/src/ui/LiquidityMakerApp.tsx +++ b/src/ui/LiquidityMakerApp.tsx @@ -1,56 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { liquidityMakerConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface LiquidityMakerAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol }); - const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: LiquidityMakerEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("liquidity-maker", { + onExit + }); if (error) { return ( diff --git a/src/ui/MakerApp.tsx b/src/ui/MakerApp.tsx index dd89c74..b06069f 100644 --- a/src/ui/MakerApp.tsx +++ b/src/ui/MakerApp.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { makerConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import { type MakerEngineSnapshot } from "../strategy/maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; +import { useStrategyEngine } from "./useStrategyEngine"; import { formatNumber } from "../utils/format"; import { t } from "../i18n"; @@ -12,45 +10,8 @@ interface MakerAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function MakerApp({ onExit }: MakerAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol }); - const engine = new MakerEngine(makerConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: MakerEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("maker", { onExit }); if (error) { return ( diff --git a/src/ui/MakerPointsApp.tsx b/src/ui/MakerPointsApp.tsx index 7bb5ed1..a5df49e 100644 --- a/src/ui/MakerPointsApp.tsx +++ b/src/ui/MakerPointsApp.tsx @@ -1,59 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { makerPointsConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface MakerPointsAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function MakerPointsApp({ onExit }: MakerPointsAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - if (exchangeId !== "standx") { - throw new Error("Maker Points strategy only supports the StandX exchange."); - } - const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol }); - const engine = new MakerPointsEngine(makerPointsConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: MakerPointsSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("maker-points", { + onExit + }); if (error) { return ( diff --git a/src/ui/OffsetMakerApp.tsx b/src/ui/OffsetMakerApp.tsx index 22c3e85..169fe05 100644 --- a/src/ui/OffsetMakerApp.tsx +++ b/src/ui/OffsetMakerApp.tsx @@ -1,56 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { makerConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; interface OffsetMakerAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol }); - const engine = new OffsetMakerEngine(makerConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: OffsetMakerEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("offset-maker", { + onExit + }); if (error) { return ( diff --git a/src/ui/SwingApp.tsx b/src/ui/SwingApp.tsx index 0e79524..1810cf1 100644 --- a/src/ui/SwingApp.tsx +++ b/src/ui/SwingApp.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { swingConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { SwingEngineSnapshot } from "../strategy/swing-engine"; import { formatNumber } from "../utils/format"; import { DataTable, type TableColumn } from "./components/DataTable"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; const READY_MESSAGE = t("swing.readyMessage"); @@ -14,45 +12,11 @@ interface SwingAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function SwingApp({ onExit }: SwingAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (_input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: swingConfig.symbol }); - const engine = new SwingEngine(swingConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: SwingEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("swing", { + onExit, + cloneSnapshot: (next) => ({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }), + }); if (error) { return ( diff --git a/src/ui/TrendApp.tsx b/src/ui/TrendApp.tsx index e2b6271..dbc9d37 100644 --- a/src/ui/TrendApp.tsx +++ b/src/ui/TrendApp.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useInput } from "ink"; -import { tradingConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; -import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; +import React from "react"; +import { Box, Text } from "ink"; +import type { TrendEngineSnapshot } from "../strategy/trend-engine"; import { formatNumber, formatTrendLabel } from "../utils/format"; import { DataTable, type TableColumn } from "./components/DataTable"; +import { useStrategyEngine } from "./useStrategyEngine"; import { t } from "../i18n"; const READY_MESSAGE = t("trend.readyMessage"); @@ -14,45 +12,10 @@ interface TrendAppProps { onExit: () => void; } -const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); - export function TrendApp({ onExit }: TrendAppProps) { - const [snapshot, setSnapshot] = useState(null); - const [error, setError] = useState(null); - const engineRef = useRef(null); - const exchangeId = useMemo(() => resolveExchangeId(), []); - const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); - - useInput( - (input, key) => { - if (key.escape) { - engineRef.current?.stop(); - onExit(); - } - }, - { isActive: inputSupported } - ); - - useEffect(() => { - try { - const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol }); - const engine = new TrendEngine(tradingConfig, adapter); - engineRef.current = engine; - setSnapshot(engine.getSnapshot()); - const handler = (next: TrendEngineSnapshot) => { - setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); - }; - engine.on("update", handler); - engine.start(); - return () => { - engine.off("update", handler); - engine.stop(); - }; - } catch (err) { - console.error(err); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, [exchangeId]); + const { snapshot, error, exchangeName } = useStrategyEngine("trend", { + onExit + }); if (error) { return ( diff --git a/src/ui/useStrategyEngine.ts b/src/ui/useStrategyEngine.ts new file mode 100644 index 0000000..f0698eb --- /dev/null +++ b/src/ui/useStrategyEngine.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useInput } from "ink"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; +import { + getStrategyDefinition, + strategyUnavailableReason, + type StrategyEngine, + type StrategySnapshot, +} from "../strategy/registry"; +import type { StrategyId } from "../strategy/strategy-ids"; + +const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); + +export interface UseStrategyEngineOptions { + /** Called when the user presses Escape, after the engine is stopped. */ + onExit: () => void; + /** + * Copies the mutable parts of a snapshot so React sees a new value. Defaults to + * a shallow copy with a fresh tradeLog; screens that render other engine-owned + * arrays must copy those too. + */ + cloneSnapshot?: (snapshot: TSnapshot) => TSnapshot; +} + +export interface UseStrategyEngineResult { + snapshot: TSnapshot | null; + error: Error | null; + exchangeName: string; +} + +function defaultClone(snapshot: TSnapshot): TSnapshot { + return { ...snapshot, tradeLog: [...snapshot.tradeLog] }; +} + +/** + * Owns a strategy engine for the lifetime of a screen: builds the adapter, wires + * the update subscription into React state, stops the engine on unmount or Escape. + * + * Availability is read from the registry, so a screen cannot disagree with the + * menu or the CLI about where its strategy may run. + */ +export function useStrategyEngine( + strategyId: StrategyId, + options: UseStrategyEngineOptions +): UseStrategyEngineResult { + const { onExit, cloneSnapshot } = options; + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const engineRef = useRef | null>(null); + const exchangeId = useMemo(() => resolveExchangeId(), []); + const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); + + const cloneRef = useRef(cloneSnapshot); + cloneRef.current = cloneSnapshot; + + useInput( + (_input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + const blocked = strategyUnavailableReason(strategyId, exchangeId); + if (blocked) { + setError(new Error(blocked)); + return; + } + try { + const definition = getStrategyDefinition(strategyId); + const adapter = buildAdapterFromEnv({ exchangeId, symbol: definition.symbol() }); + const engine = definition.createEngine(adapter) as StrategyEngine; + engineRef.current = engine; + setSnapshot(engine.getSnapshot()); + + const handler = (next: TSnapshot) => { + setSnapshot((cloneRef.current ?? defaultClone)(next)); + }; + engine.on("update", handler); + engine.start(); + return () => { + engine.off("update", handler); + engine.stop(); + engineRef.current = null; + }; + } catch (err) { + console.error(err); + setError(err instanceof Error ? err : new Error(String(err))); + return; + } + }, [exchangeId, strategyId]); + + return { snapshot, error, exchangeName }; +}