mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
refactor(strategy): collapse six strategy lists into one registry
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.
This commit is contained in:
+7
-24
@@ -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<StrategyId>([
|
||||
"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 <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--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` +
|
||||
|
||||
+55
-227
@@ -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<void>;
|
||||
|
||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||
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<StrategyId, string>;
|
||||
|
||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
||||
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<StrategyId, StrategyRunner> = {
|
||||
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<TSnapshot> {
|
||||
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<TSnapshot>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void>((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}`;
|
||||
}
|
||||
|
||||
@@ -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<TSnapshot extends StrategySnapshot = StrategySnapshot> {
|
||||
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<StrategyId, StrategyDefinition> = {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, StrategyId> = {
|
||||
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;
|
||||
}
|
||||
+25
-78
@@ -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<StrategyId, StrategyView> = {
|
||||
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<StrategyOption | null>(null);
|
||||
const [selected, setSelected] = useState<StrategyId | null>(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 <Selected onExit={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
@@ -136,9 +83,9 @@ export function App() {
|
||||
return (
|
||||
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={active ? "greenBright" : undefined}>
|
||||
{active ? "➤" : " "} {strategy.label}
|
||||
{active ? "➤" : " "} {t(strategy.labelKey)}
|
||||
</Text>
|
||||
<Text color="gray"> {strategy.description}</Text>
|
||||
<Text color="gray"> {t(strategy.descriptionKey)}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user