Files
ritmex-bot/src/cli/strategy-runner.ts
T
discountry 9538ecf265 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.
2026-07-29 20:36:55 +08:00

119 lines
4.2 KiB
TypeScript

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 {
getStrategyDefinition,
strategyUnavailableReason,
STRATEGY_DEFINITIONS,
type StrategyEngine,
type StrategySnapshot,
} from "../strategy/registry";
import { extractMessage } from "../utils/errors";
import type { StrategyId } from "../strategy/strategy-ids";
interface RunnerOptions {
silent?: boolean;
dryRun?: boolean;
}
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 definition = getStrategyDefinition(strategyId);
if (!definition) {
throw new Error(`Unsupported strategy: ${strategyId}`);
}
const exchangeId = resolveExchangeId();
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: StrategySnapshot) => {
if (!Array.isArray(snapshot.tradeLog)) return;
if (!readyLogged && snapshot.ready) {
readyLogged = true;
console.info(`[${label}] Strategy ready. Listening for market data…`);
}
const pending = diffTradeLog(snapshot.tradeLog, lastLogKey);
if (!pending.length) return;
for (const entry of pending) {
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
}
lastLogKey = lastKeyOf(pending) ?? lastLogKey;
};
engine.on("update", emitter);
engine.start();
const modeLabel = `${options.silent ? "silent" : "interactive"}${options.dryRun ? "+dry-run" : ""}`;
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
await new Promise<void>((resolve) => {
const wrapper = (signal: NodeJS.Signals) => {
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();
};
process.on("SIGINT", wrapper);
process.on("SIGTERM", wrapper);
});
}
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
return dryRun ? new DryRunExchangeAdapter(adapter) : adapter;
}
type TradeLogEntry = { time: string; type: string; detail: string };
function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): TradeLogEntry[] {
if (!tradeLog.length) return [];
if (!lastKey) return tradeLog;
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
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}`;
}