From 9538ecf2658350636f35a54edb91fcb0146b3fc4 Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 29 Jul 2026 20:36:55 +0800 Subject: [PATCH] refactor(strategy): collapse six strategy lists into one registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a strategy meant editing six places that had to agree: the StrategyId union and a parallel Set in args.ts, STRATEGY_LABELS and a nine-branch STRATEGY_FACTORIES in strategy-runner.ts, plus an inline id union and BASE_STRATEGIES in App.tsx. runEngine's type parameter was additionally bounded by a union of all nine snapshot types. They had already drifted: the CLI gated basis on isBasisSupportedExchangeId while the menu checked only isBasisStrategyEnabled, so the menu offered basis on exchanges where startStrategy would throw. - strategy-ids.ts: the id list and alias parsing, dependency-free so CLI arg parsing does not pull in every engine. - registry.ts: one definition per strategy (labels, symbol, engine factory, and a single unavailableReason both the menu and the runner consult), keyed by a total Record so a new id will not compile until it is defined. - StrategyEngine/StrategySnapshot interfaces replace the snapshot union; runEngine now depends only on the contract. All nine engines already satisfied it — no engine changed. - App.tsx keeps only the id -> Ink view map, also a total Record. Menu order preserved. 8 new tests, one pinning menu/CLI availability agreement. 234 pass; tsc clean. -242 lines. --- src/cli/args.ts | 31 +--- src/cli/strategy-runner.ts | 282 +++++++------------------------- src/strategy/registry.ts | 165 +++++++++++++++++++ src/strategy/strategy-ids.ts | 44 +++++ src/ui/App.tsx | 103 +++--------- tests/strategy-registry.test.ts | 69 ++++++++ 6 files changed, 365 insertions(+), 329 deletions(-) create mode 100644 src/strategy/registry.ts create mode 100644 src/strategy/strategy-ids.ts create mode 100644 tests/strategy-registry.test.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index c6b58d6..41dd5b2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,6 +1,7 @@ import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter"; +import { STRATEGY_IDS, parseStrategyId, type StrategyId } from "../strategy/strategy-ids"; -export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; +export type { StrategyId }; export interface CliOptions { strategy?: StrategyId; @@ -9,18 +10,6 @@ export interface CliOptions { exchange?: SupportedExchangeId; } -const STRATEGY_VALUES = new Set([ - "trend", - "swing", - "guardian", - "maker", - "maker-points", - "offset-maker", - "liquidity-maker", - "basis", - "grid", -]); - export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions { const options: CliOptions = { silent: false, help: false }; @@ -68,16 +57,9 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions } function assignStrategy(options: CliOptions, raw: string): void { - const normalized = raw.trim().toLowerCase(); - if (!normalized) return; - if (STRATEGY_VALUES.has(normalized as StrategyId)) { - options.strategy = normalized as StrategyId; - } else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") { - options.strategy = "offset-maker"; - } else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") { - options.strategy = "maker-points"; - } else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") { - options.strategy = "liquidity-maker"; + const strategy = parseStrategyId(raw); + if (strategy) { + options.strategy = strategy; } } @@ -95,8 +77,9 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|"); + const strategyList = STRATEGY_IDS.join("|"); // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange <${exchangeList}>] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy <${strategyList}>] [--exchange <${exchangeList}>] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index 6d37ff0..1d78def 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,224 +1,58 @@ -import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config"; -import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter"; -import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; -import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; -import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine"; -import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; -import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; -import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine"; -import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; -import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; -import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; +import { + getStrategyDefinition, + strategyUnavailableReason, + STRATEGY_DEFINITIONS, + type StrategyEngine, + type StrategySnapshot, +} from "../strategy/registry"; import { extractMessage } from "../utils/errors"; -import type { StrategyId } from "./args"; +import type { StrategyId } from "../strategy/strategy-ids"; interface RunnerOptions { silent?: boolean; dryRun?: boolean; } -type StrategyRunner = (options: RunnerOptions) => Promise; - -export const STRATEGY_LABELS: Record = { - trend: "Trend Following", - swing: "Swing", - guardian: "Guardian", - maker: "Maker", - "maker-points": "Maker Points", - "offset-maker": "Offset Maker", - "liquidity-maker": "Liquidity Maker", - basis: "Basis Arbitrage", - grid: "Grid", -}; +export const STRATEGY_LABELS = Object.fromEntries( + STRATEGY_DEFINITIONS.map((definition) => [definition.id, definition.consoleLabel]) +) as Record; export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise { - const runner = STRATEGY_FACTORIES[strategyId]; - if (!runner) { + const definition = getStrategyDefinition(strategyId); + if (!definition) { throw new Error(`Unsupported strategy: ${strategyId}`); } - await runner(options); -} - -const STRATEGY_FACTORIES: Record = { - trend: async (opts) => { - const config = tradingConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new TrendEngine(config, adapter); - await runEngine({ - engine, - strategy: "trend", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - swing: async (opts) => { - const config = swingConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new SwingEngine(config, adapter); - await runEngine({ - engine, - strategy: "swing", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - guardian: async (opts) => { - const config = tradingConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new GuardianEngine(config, adapter); - await runEngine({ - engine, - strategy: "guardian", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - maker: async (opts) => { - const config = makerConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new MakerEngine(config, adapter); - await runEngine({ - engine, - strategy: "maker", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - "maker-points": async (opts) => { - const exchangeId = resolveExchangeId(); - if (exchangeId !== "standx") { - throw new Error("Maker Points strategy only supports the StandX exchange."); - } - const config = makerPointsConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new MakerPointsEngine(config, adapter); - await runEngine({ - engine, - strategy: "maker-points", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - "offset-maker": async (opts) => { - const config = makerConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new OffsetMakerEngine(config, adapter); - await runEngine({ - engine, - strategy: "offset-maker", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - "liquidity-maker": async (opts) => { - const config = liquidityMakerConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new LiquidityMakerEngine(config, adapter); - await runEngine({ - engine, - strategy: "liquidity-maker", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - basis: async (opts) => { - if (!isBasisStrategyEnabled()) { - throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it."); - } - const exchangeId = resolveExchangeId(); - if (!isBasisSupportedExchangeId(exchangeId)) { - throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges"); - } - const adapter = createAdapterOrThrow(basisConfig.futuresSymbol, opts.dryRun); - const engine = new BasisArbEngine(basisConfig, adapter); - await runEngine({ - engine, - strategy: "basis", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, - grid: async (opts) => { - const config = gridConfig; - const adapter = createAdapterOrThrow(config.symbol, opts.dryRun); - const engine = new GridEngine(config, adapter); - await runEngine({ - engine, - strategy: "grid", - silent: opts.silent, - dryRun: opts.dryRun, - getSnapshot: () => engine.getSnapshot(), - onUpdate: (emitter) => engine.on("update", emitter), - offUpdate: (emitter) => engine.off("update", emitter), - }); - }, -}; - -interface EngineHarness { - engine: { start(): void; stop(): void }; - strategy: StrategyId; - silent?: boolean; - dryRun?: boolean; - getSnapshot: () => TSnapshot; - onUpdate: (handler: (snapshot: TSnapshot) => void) => void; - offUpdate: (handler: (snapshot: TSnapshot) => void) => void; -} - -async function runEngine< - TSnapshot extends - | TrendEngineSnapshot - | SwingEngineSnapshot - | GuardianEngineSnapshot - | MakerEngineSnapshot - | MakerPointsSnapshot - | OffsetMakerEngineSnapshot - | LiquidityMakerEngineSnapshot - | BasisArbSnapshot - | GridEngineSnapshot ->( - harness: EngineHarness -): Promise { - const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness; const exchangeId = resolveExchangeId(); - const exchangeName = getExchangeDisplayName(exchangeId); - const label = STRATEGY_LABELS[strategy]; - - const initial = getSnapshot(); - let lastLogKey: string | undefined; - if (Array.isArray(initial.tradeLog) && initial.tradeLog.length > 0) { - const lastEntry = initial.tradeLog[initial.tradeLog.length - 1]!; - lastLogKey = createLogKey(lastEntry); + const blocked = strategyUnavailableReason(strategyId, exchangeId); + if (blocked) { + throw new Error(blocked); } + + const adapter = createAdapterOrThrow(definition.symbol(), options.dryRun); + const engine = definition.createEngine(adapter); + await runEngine(engine, definition.consoleLabel, options); +} + +/** + * Streams an engine's trade log to the console until SIGINT/SIGTERM, then stops it. + * Depends only on the StrategyEngine contract, so a new strategy needs no change here. + */ +async function runEngine( + engine: StrategyEngine, + label: string, + options: RunnerOptions +): Promise { + const exchangeName = getExchangeDisplayName(resolveExchangeId()); + + const initial = engine.getSnapshot(); + let lastLogKey = lastKeyOf(initial.tradeLog); let readyLogged = initial.ready === true; - const emitter = (snapshot: TSnapshot) => { + const emitter = (snapshot: StrategySnapshot) => { if (!Array.isArray(snapshot.tradeLog)) return; if (!readyLogged && snapshot.ready) { readyLogged = true; @@ -229,31 +63,24 @@ async function runEngine< for (const entry of pending) { console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`); } - const lastEntry = pending[pending.length - 1]!; - if (lastEntry) { - lastLogKey = createLogKey(lastEntry); - } + lastLogKey = lastKeyOf(pending) ?? lastLogKey; }; - onUpdate(emitter); + engine.on("update", emitter); engine.start(); - const modeLabel = `${silent ? "silent" : "interactive"}${harness.dryRun ? "+dry-run" : ""}`; + const modeLabel = `${options.silent ? "silent" : "interactive"}${options.dryRun ? "+dry-run" : ""}`; console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`); - const shutdown = (signal: NodeJS.Signals) => { - try { - console.info(`[${label}] Received ${signal}. Shutting down…`); - engine.stop(); - offUpdate(emitter); - } catch (error) { - console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`); - } - }; - await new Promise((resolve) => { const wrapper = (signal: NodeJS.Signals) => { - shutdown(signal); + try { + console.info(`[${label}] Received ${signal}. Shutting down…`); + engine.stop(); + engine.off("update", emitter); + } catch (error) { + console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`); + } process.off("SIGINT", wrapper); process.off("SIGTERM", wrapper); resolve(); @@ -266,10 +93,7 @@ async function runEngine< function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter { const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol }); - if (dryRun) { - return new DryRunExchangeAdapter(adapter); - } - return adapter; + return dryRun ? new DryRunExchangeAdapter(adapter) : adapter; } type TradeLogEntry = { time: string; type: string; detail: string }; @@ -278,13 +102,17 @@ function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): T if (!tradeLog.length) return []; if (!lastKey) return tradeLog; const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey); - if (lastIndex === -1) { - return tradeLog; - } + if (lastIndex === -1) return tradeLog; if (lastIndex === tradeLog.length - 1) return []; return tradeLog.slice(lastIndex + 1); } +function lastKeyOf(entries: TradeLogEntry[] | undefined): string | undefined { + if (!Array.isArray(entries) || entries.length === 0) return undefined; + const last = entries[entries.length - 1]; + return last ? createLogKey(last) : undefined; +} + function createLogKey(entry: TradeLogEntry): string { return `${entry.time}|${entry.type}|${entry.detail}`; } diff --git a/src/strategy/registry.ts b/src/strategy/registry.ts new file mode 100644 index 0000000..663a8dd --- /dev/null +++ b/src/strategy/registry.ts @@ -0,0 +1,165 @@ +import { + basisConfig, + gridConfig, + isBasisStrategyEnabled, + liquidityMakerConfig, + makerConfig, + makerPointsConfig, + swingConfig, + tradingConfig, +} from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import { isBasisSupportedExchangeId, type SupportedExchangeId } from "../exchanges/create-adapter"; +import type { TradeLogEntry } from "../logging/trade-log"; +import { BasisArbEngine } from "./basis-arb-engine"; +import { GridEngine } from "./grid-engine"; +import { GuardianEngine } from "./guardian-engine"; +import { LiquidityMakerEngine } from "./liquidity-maker-engine"; +import { MakerEngine } from "./maker-engine"; +import { MakerPointsEngine } from "./maker-points-engine"; +import { OffsetMakerEngine } from "./offset-maker-engine"; +import { SwingEngine } from "./swing-engine"; +import { TrendEngine } from "./trend-engine"; +import { STRATEGY_IDS, type StrategyId } from "./strategy-ids"; + +/** The slice of every engine snapshot that generic consumers (CLI, UI) rely on. */ +export interface StrategySnapshot { + ready: boolean; + tradeLog: TradeLogEntry[]; +} + +/** + * What every strategy engine offers its host. Consumers depend on this instead of + * the nine concrete classes, so neither the runner nor the UI needs a union of + * snapshot types that grows with each new strategy. + */ +export interface StrategyEngine { + start(): void; + stop(): void; + getSnapshot(): TSnapshot; + on(event: "update", handler: (snapshot: TSnapshot) => void): void; + off(event: "update", handler: (snapshot: TSnapshot) => void): void; +} + +export interface StrategyDefinition { + id: StrategyId; + /** Prefix for non-interactive console output; not translated. */ + consoleLabel: string; + labelKey: string; + descriptionKey: string; + /** Market the adapter must be built for before the engine is constructed. */ + symbol(): string; + createEngine(adapter: ExchangeAdapter): StrategyEngine; + /** + * Why this strategy cannot run in the current environment, or null when it can. + * The menu hides strategies with a reason; the CLI reports it. One predicate + * keeps those two surfaces from disagreeing. + */ + unavailableReason?(exchangeId: SupportedExchangeId): string | null; +} + +/** + * Keyed by StrategyId so a new id in strategy-ids.ts is a compile error here + * until it gets a definition. + */ +const DEFINITIONS: Record = { + trend: { + id: "trend", + consoleLabel: "Trend Following", + labelKey: "app.strategy.trend.label", + descriptionKey: "app.strategy.trend.desc", + symbol: () => tradingConfig.symbol, + createEngine: (adapter) => new TrendEngine(tradingConfig, adapter), + }, + swing: { + id: "swing", + consoleLabel: "Swing", + labelKey: "app.strategy.swing.label", + descriptionKey: "app.strategy.swing.desc", + symbol: () => swingConfig.symbol, + createEngine: (adapter) => new SwingEngine(swingConfig, adapter), + }, + guardian: { + id: "guardian", + consoleLabel: "Guardian", + labelKey: "app.strategy.guardian.label", + descriptionKey: "app.strategy.guardian.desc", + symbol: () => tradingConfig.symbol, + createEngine: (adapter) => new GuardianEngine(tradingConfig, adapter), + }, + maker: { + id: "maker", + consoleLabel: "Maker", + labelKey: "app.strategy.maker.label", + descriptionKey: "app.strategy.maker.desc", + symbol: () => makerConfig.symbol, + createEngine: (adapter) => new MakerEngine(makerConfig, adapter), + }, + grid: { + id: "grid", + consoleLabel: "Grid", + labelKey: "app.strategy.grid.label", + descriptionKey: "app.strategy.grid.desc", + symbol: () => gridConfig.symbol, + createEngine: (adapter) => new GridEngine(gridConfig, adapter), + }, + "maker-points": { + id: "maker-points", + consoleLabel: "Maker Points", + labelKey: "app.strategy.makerPoints.label", + descriptionKey: "app.strategy.makerPoints.desc", + symbol: () => makerPointsConfig.symbol, + createEngine: (adapter) => new MakerPointsEngine(makerPointsConfig, adapter), + unavailableReason: (exchangeId) => + exchangeId === "standx" ? null : "Maker Points strategy only supports the StandX exchange.", + }, + "offset-maker": { + id: "offset-maker", + consoleLabel: "Offset Maker", + labelKey: "app.strategy.offset.label", + descriptionKey: "app.strategy.offset.desc", + symbol: () => makerConfig.symbol, + createEngine: (adapter) => new OffsetMakerEngine(makerConfig, adapter), + }, + "liquidity-maker": { + id: "liquidity-maker", + consoleLabel: "Liquidity Maker", + labelKey: "app.strategy.liquidityMaker.label", + descriptionKey: "app.strategy.liquidityMaker.desc", + symbol: () => liquidityMakerConfig.symbol, + createEngine: (adapter) => new LiquidityMakerEngine(liquidityMakerConfig, adapter), + }, + basis: { + id: "basis", + consoleLabel: "Basis Arbitrage", + labelKey: "app.strategy.basis.label", + descriptionKey: "app.strategy.basis.desc", + symbol: () => basisConfig.futuresSymbol, + createEngine: (adapter) => new BasisArbEngine(basisConfig, adapter), + unavailableReason: (exchangeId) => { + if (!isBasisStrategyEnabled()) { + return "Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it."; + } + if (!isBasisSupportedExchangeId(exchangeId)) { + return "Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges"; + } + return null; + }, + }, +}; + +/** Menu order. */ +export const STRATEGY_DEFINITIONS: readonly StrategyDefinition[] = STRATEGY_IDS.map((id) => DEFINITIONS[id]); + +export function getStrategyDefinition(id: StrategyId): StrategyDefinition { + return DEFINITIONS[id]; +} + +export function strategyUnavailableReason(id: StrategyId, exchangeId: SupportedExchangeId): string | null { + return DEFINITIONS[id].unavailableReason?.(exchangeId) ?? null; +} + +/** Strategies runnable on this exchange, in menu order. */ +export function availableStrategies(exchangeId: SupportedExchangeId): StrategyDefinition[] { + return STRATEGY_DEFINITIONS.filter((definition) => definition.unavailableReason?.(exchangeId) == null); +} diff --git a/src/strategy/strategy-ids.ts b/src/strategy/strategy-ids.ts new file mode 100644 index 0000000..9fb3724 --- /dev/null +++ b/src/strategy/strategy-ids.ts @@ -0,0 +1,44 @@ +/** + * The canonical list of strategies. Dependency-free on purpose: CLI argument + * parsing imports this without dragging in every engine and its config. + * + * Adding a strategy starts here; `strategy/registry.ts` then fails to compile + * until the new id has a definition. + */ +/** Order is the interactive menu's order. */ +export const STRATEGY_IDS = [ + "trend", + "swing", + "guardian", + "maker", + "maker-points", + "grid", + "offset-maker", + "liquidity-maker", + "basis", +] as const; + +export type StrategyId = (typeof STRATEGY_IDS)[number]; + +/** Spellings accepted on the command line beyond the canonical ids. */ +const STRATEGY_ALIASES: Record = { + offset: "offset-maker", + offsetmaker: "offset-maker", + makerpoints: "maker-points", + maker_points: "maker-points", + liquidity: "liquidity-maker", + liquiditymaker: "liquidity-maker", + liquidity_maker: "liquidity-maker", +}; + +export function isStrategyId(value: string): value is StrategyId { + return (STRATEGY_IDS as readonly string[]).includes(value); +} + +/** @returns the strategy the input names, or null when it names none. */ +export function parseStrategyId(raw: string): StrategyId | null { + const normalized = raw.trim().toLowerCase(); + if (!normalized) return null; + if (isStrategyId(normalized)) return normalized; + return STRATEGY_ALIASES[normalized] ?? null; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8cd2876..6c3b00a 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -9,93 +9,40 @@ import { OffsetMakerApp } from "./OffsetMakerApp"; import { LiquidityMakerApp } from "./LiquidityMakerApp"; import { GridApp } from "./GridApp"; import { BasisApp } from "./BasisApp"; -import { isBasisStrategyEnabled } from "../config"; import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright"; import { resolveExchangeId } from "../exchanges/create-adapter"; +import { availableStrategies } from "../strategy/registry"; +import type { StrategyId } from "../strategy/strategy-ids"; import { t } from "../i18n"; -interface StrategyOption { - id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; - label: string; - description: string; - component: React.ComponentType<{ onExit: () => void }>; -} +type StrategyView = React.ComponentType<{ onExit: () => void }>; -const BASE_STRATEGIES: StrategyOption[] = [ - { - id: "trend", - label: t("app.strategy.trend.label"), - description: t("app.strategy.trend.desc"), - component: TrendApp, - }, - { - id: "swing", - label: t("app.strategy.swing.label"), - description: t("app.strategy.swing.desc"), - component: SwingApp, - }, - { - id: "guardian", - label: t("app.strategy.guardian.label"), - description: t("app.strategy.guardian.desc"), - component: GuardianApp, - }, - { - id: "maker", - label: t("app.strategy.maker.label"), - description: t("app.strategy.maker.desc"), - component: MakerApp, - }, - { - id: "grid", - label: t("app.strategy.grid.label"), - description: t("app.strategy.grid.desc"), - component: GridApp, - }, - { - id: "offset-maker", - label: t("app.strategy.offset.label"), - description: t("app.strategy.offset.desc"), - component: OffsetMakerApp, - }, - { - id: "liquidity-maker", - label: t("app.strategy.liquidityMaker.label"), - description: t("app.strategy.liquidityMaker.desc"), - component: LiquidityMakerApp, - }, -]; +/** + * The only strategy knowledge the UI owns: which screen renders which engine. + * Typed as a total Record, so adding a strategy to the registry fails to compile + * here until it has a view. + */ +const STRATEGY_VIEWS: Record = { + trend: TrendApp, + swing: SwingApp, + guardian: GuardianApp, + maker: MakerApp, + "maker-points": MakerPointsApp, + grid: GridApp, + "offset-maker": OffsetMakerApp, + "liquidity-maker": LiquidityMakerApp, + basis: BasisApp, +}; const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function App() { const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const copyright = useMemo(() => loadCopyrightFragments(), []); const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []); const exchangeId = useMemo(() => resolveExchangeId(), []); - const strategies = useMemo(() => { - const next: StrategyOption[] = [...BASE_STRATEGIES]; - if (exchangeId === "standx") { - const gridIndex = next.findIndex((s) => s.id === "grid"); - const insertAt = gridIndex === -1 ? next.length : gridIndex; - next.splice(insertAt, 0, { - id: "maker-points" as const, - label: t("app.strategy.makerPoints.label"), - description: t("app.strategy.makerPoints.desc"), - component: MakerPointsApp, - }); - } - if (isBasisStrategyEnabled()) { - next.push({ - id: "basis" as const, - label: t("app.strategy.basis.label"), - description: t("app.strategy.basis.desc"), - component: BasisApp, - }); - } - return next; - }, [exchangeId]); + const strategies = useMemo(() => availableStrategies(exchangeId), [exchangeId]); useInput( (input, key) => { @@ -107,7 +54,7 @@ export function App() { } else if (key.return) { const strategy = strategies[cursor]; if (strategy) { - setSelected(strategy); + setSelected(strategy.id); } } }, @@ -115,7 +62,7 @@ export function App() { ); if (selected) { - const Selected = selected.component; + const Selected = STRATEGY_VIEWS[selected]; return setSelected(null)} />; } @@ -136,9 +83,9 @@ export function App() { return ( - {active ? "➤" : " "} {strategy.label} + {active ? "➤" : " "} {t(strategy.labelKey)} - {strategy.description} + {t(strategy.descriptionKey)} ); })} diff --git a/tests/strategy-registry.test.ts b/tests/strategy-registry.test.ts new file mode 100644 index 0000000..3fe78ab --- /dev/null +++ b/tests/strategy-registry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + STRATEGY_DEFINITIONS, + availableStrategies, + getStrategyDefinition, + strategyUnavailableReason, +} from "../src/strategy/registry"; +import { STRATEGY_IDS, isStrategyId, parseStrategyId } from "../src/strategy/strategy-ids"; + +describe("strategy ids", () => { + it("resolves canonical ids and documented aliases", () => { + expect(parseStrategyId("trend")).toBe("trend"); + expect(parseStrategyId(" GRID ")).toBe("grid"); + expect(parseStrategyId("offset")).toBe("offset-maker"); + expect(parseStrategyId("offsetmaker")).toBe("offset-maker"); + expect(parseStrategyId("makerpoints")).toBe("maker-points"); + expect(parseStrategyId("maker_points")).toBe("maker-points"); + expect(parseStrategyId("liquidity")).toBe("liquidity-maker"); + expect(parseStrategyId("liquidity_maker")).toBe("liquidity-maker"); + }); + + it("rejects unknown names", () => { + expect(parseStrategyId("nope")).toBeNull(); + expect(parseStrategyId("")).toBeNull(); + expect(isStrategyId("nope")).toBe(false); + }); +}); + +describe("strategy registry", () => { + it("defines every id exactly once, in menu order", () => { + expect(STRATEGY_DEFINITIONS.map((d) => d.id)).toEqual([...STRATEGY_IDS]); + expect(new Set(STRATEGY_DEFINITIONS.map((d) => d.id)).size).toBe(STRATEGY_IDS.length); + }); + + it("gives every strategy a console label and i18n keys", () => { + for (const definition of STRATEGY_DEFINITIONS) { + expect(definition.consoleLabel).toBeTruthy(); + expect(definition.labelKey).toMatch(/^app\.strategy\./); + expect(definition.descriptionKey).toMatch(/^app\.strategy\./); + expect(typeof definition.symbol()).toBe("string"); + } + }); + + it("gates maker-points to StandX", () => { + expect(strategyUnavailableReason("maker-points", "standx")).toBeNull(); + expect(strategyUnavailableReason("maker-points", "aster")).toContain("StandX"); + }); + + it("keeps the menu and the CLI on one availability rule", () => { + // The menu shows exactly what startStrategy would accept — the two used to + // disagree, so basis appeared on exchanges where the runner then threw. + for (const exchangeId of ["aster", "standx", "backpack"] as const) { + const shown = availableStrategies(exchangeId).map((d) => d.id); + const runnable = STRATEGY_IDS.filter((id) => strategyUnavailableReason(id, exchangeId) == null); + expect(shown).toEqual(runnable); + } + }); + + it("hides strategies whose environment gate is closed", () => { + const shown = availableStrategies("backpack").map((d) => d.id); + expect(shown).not.toContain("maker-points"); + }); + + it("exposes an engine factory per strategy", () => { + for (const id of STRATEGY_IDS) { + expect(typeof getStrategyDefinition(id).createEngine).toBe("function"); + } + }); +});