From 2c98664412f3ea10880e842a803df31cd2bea5bd Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 29 Jul 2026 20:39:16 +0800 Subject: [PATCH] refactor(ui): extract useStrategyEngine from 9 duplicated screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen repeated the same block: resolve the exchange, build an adapter, construct an engine, hold it in a ref, subscribe, clone the snapshot into state, stop on unmount, and stop again on Escape. Roughly 45 lines each, differing only in which engine they built and which arrays they cloned. Two of them also carried their own copy of an availability rule — MakerPointsApp re-checked 'standx' and BasisApp re-checked isBasisSupportedExchangeId — a third and fourth statement of what the registry now owns. The hook reads strategyUnavailableReason instead, so a screen cannot drift from the menu. Screens now declare a strategy id and, when they render engine-owned arrays beyond tradeLog, a cloneSnapshot. Engine construction leaves the view layer entirely. 234 pass; tsc and oxlint clean; menu renders unchanged. -338 lines. --- src/ui/BasisApp.tsx | 54 +++----------------- src/ui/GridApp.tsx | 61 +++++----------------- src/ui/GuardianApp.tsx | 51 +++---------------- src/ui/LiquidityMakerApp.tsx | 51 +++---------------- src/ui/MakerApp.tsx | 49 ++---------------- src/ui/MakerPointsApp.tsx | 54 +++----------------- src/ui/OffsetMakerApp.tsx | 51 +++---------------- src/ui/SwingApp.tsx | 52 +++---------------- src/ui/TrendApp.tsx | 51 +++---------------- src/ui/useStrategyEngine.ts | 98 ++++++++++++++++++++++++++++++++++++ 10 files changed, 166 insertions(+), 406 deletions(-) create mode 100644 src/ui/useStrategyEngine.ts 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 }; +}