Enhance CLI command mode for ritmex-bot (#23)

- Introduced a new command mode for `ritmex-bot`, allowing agent-friendly structured trading operations without entering the Ink interactive menu.
- Updated `package.json` to include versioning and set the project as public with a new CLI entry point.
- Enhanced documentation in both English and Chinese to provide comprehensive usage instructions for the new command mode.
- Added a new executable script for `ritmex-bot` to facilitate command execution.
- Improved error handling and command parsing for better user experience and clarity in command execution.
This commit is contained in:
Disney
2026-02-27 12:42:20 +08:00
committed by GitHub
parent d6399b92aa
commit 4014a86223
22 changed files with 3319 additions and 53 deletions
+6 -1
View File
@@ -101,5 +101,10 @@ export function printCliHelp(): void {
` Aliases: liquidity, liquidity-maker for the liquidity maker engine.\n` +
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
` --help, -h Show this help message.\n`);
` --help, -h Show this help message.\n\n` +
`Command mode:\n` +
` ritmex-bot doctor\n` +
` ritmex-bot exchange list\n` +
` ritmex-bot market ticker --exchange <id> --symbol <symbol>\n` +
` ritmex-bot order create --side buy --type limit --quantity 0.01 --price 100000 --dry-run\n`);
}
+670
View File
@@ -0,0 +1,670 @@
import { resolveSymbolFromEnv } from "../config";
import { type ExchangeAdapter } from "../exchanges/adapter";
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
import {
SUPPORTED_EXCHANGE_IDS,
getExchangeDisplayName,
resolveExchangeId,
type SupportedExchangeId,
} from "../exchanges/create-adapter";
import {
routeCloseOrder,
routeLimitOrder,
routeMarketOrder,
routeStopOrder,
routeTrailingStopOrder,
} from "../exchanges/order-router";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import type { AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../exchanges/types";
import { startStrategy } from "./strategy-runner";
import type {
CommandErrorPayload,
CommandExecutionResult,
CommandFailurePayload,
CommandPayload,
CommandSuccessPayload,
ParsedCliCommand,
} from "./command-types";
const EXIT_CODE_SUCCESS = 0;
const EXIT_CODE_INVALID_ARGS = 2;
const EXIT_CODE_MISSING_ENV = 3;
const EXIT_CODE_UNSUPPORTED = 5;
const EXIT_CODE_EXCHANGE_ERROR = 6;
const EXIT_CODE_TIMEOUT = 7;
const STATIC_CAPABILITIES: Record<
SupportedExchangeId,
{
trailingStops: boolean | "conditional";
fundingRate: boolean;
precision: boolean;
queryOpenOrders: boolean;
queryAccountSnapshot: boolean;
changeMarginMode: boolean;
forceCancelAllOrders: boolean;
}
> = {
aster: {
trailingStops: true,
fundingRate: false,
precision: true,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
grvt: {
trailingStops: false,
fundingRate: false,
precision: false,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
lighter: {
trailingStops: false,
fundingRate: false,
precision: true,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
backpack: {
trailingStops: false,
fundingRate: false,
precision: false,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
paradex: {
trailingStops: false,
fundingRate: false,
precision: false,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
nado: {
trailingStops: false,
fundingRate: true,
precision: true,
queryOpenOrders: false,
queryAccountSnapshot: false,
changeMarginMode: false,
forceCancelAllOrders: false,
},
standx: {
trailingStops: false,
fundingRate: true,
precision: true,
queryOpenOrders: true,
queryAccountSnapshot: true,
changeMarginMode: true,
forceCancelAllOrders: true,
},
binance: {
trailingStops: "conditional",
fundingRate: true,
precision: true,
queryOpenOrders: true,
queryAccountSnapshot: true,
changeMarginMode: true,
forceCancelAllOrders: true,
},
};
export interface CommandExecutorDependencies {
buildAdapterFromEnvFn?: typeof buildAdapterFromEnv;
startStrategyFn?: typeof startStrategy;
now?: () => number;
}
class CommandExecutionError extends Error {
constructor(
readonly code: CommandErrorPayload["code"],
readonly exitCode: number,
message: string,
readonly retryable: boolean = false,
readonly details?: unknown
) {
super(message);
this.name = "CommandExecutionError";
}
}
export async function executeCliCommand(
command: ParsedCliCommand,
deps: CommandExecutorDependencies = {}
): Promise<CommandExecutionResult> {
const buildAdapterFromEnvFn = deps.buildAdapterFromEnvFn ?? buildAdapterFromEnv;
const startStrategyFn = deps.startStrategyFn ?? startStrategy;
const now = deps.now ?? (() => Date.now());
try {
const data = await withExchangeOverride(command.exchange, async () => {
switch (command.kind) {
case "help":
return { topic: command.topic ?? null };
case "doctor":
return handleDoctor(command, buildAdapterFromEnvFn);
case "exchange-list":
return {
exchanges: SUPPORTED_EXCHANGE_IDS.map((id) => ({
id,
name: getExchangeDisplayName(id),
})),
};
case "exchange-capabilities":
return handleExchangeCapabilities(command, buildAdapterFromEnvFn);
case "market-ticker":
return handleMarketTicker(command, buildAdapterFromEnvFn);
case "market-depth":
return handleMarketDepth(command, buildAdapterFromEnvFn);
case "market-kline":
return handleMarketKline(command, buildAdapterFromEnvFn);
case "account-snapshot":
return handleAccountSnapshot(command, buildAdapterFromEnvFn);
case "position-list":
return handlePositionList(command, buildAdapterFromEnvFn);
case "order-open":
return handleOrderOpen(command, buildAdapterFromEnvFn);
case "order-create":
return handleOrderCreate(command, buildAdapterFromEnvFn);
case "order-cancel":
return handleOrderCancel(command, buildAdapterFromEnvFn);
case "order-cancel-all":
return handleOrderCancelAll(command, buildAdapterFromEnvFn);
case "strategy-run":
await startStrategyFn(command.strategy, { silent: command.silent, dryRun: command.dryRun });
return {
strategy: command.strategy,
status: "stopped",
};
}
});
const payload = successPayload(command, now(), data);
return {
exitCode: EXIT_CODE_SUCCESS,
payload,
forceExit: command.kind !== "strategy-run",
};
} catch (error) {
const mapped = mapToCommandExecutionError(error);
const payload = failurePayload(command, now(), mapped);
return {
exitCode: mapped.exitCode,
payload,
forceExit: true,
};
}
}
export function renderCommandPayload(payload: CommandPayload, json: boolean): string {
if (json) {
return JSON.stringify(payload, null, 2);
}
if (payload.success) {
return formatHumanSuccess(payload);
}
return formatHumanError(payload);
}
async function handleDoctor(
command: Extract<ParsedCliCommand, { kind: "doctor" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const exchange = resolveEffectiveExchange(command.exchange);
const symbol = resolveEffectiveSymbol(command.symbol, exchange);
const adapter = createAdapter(buildAdapterFromEnvFn, exchange, symbol);
return {
exchange,
exchangeName: getExchangeDisplayName(exchange),
symbol,
adapterId: adapter.id,
capabilities: runtimeCapabilities(adapter),
};
}
async function handleExchangeCapabilities(
command: Extract<ParsedCliCommand, { kind: "exchange-capabilities" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const exchange = resolveEffectiveExchange(command.exchange);
const symbol = resolveEffectiveSymbol(command.symbol, exchange);
const staticCapabilities = STATIC_CAPABILITIES[exchange];
try {
const adapter = createAdapter(buildAdapterFromEnvFn, exchange, symbol);
return {
exchange,
exchangeName: getExchangeDisplayName(exchange),
symbol,
capabilities: runtimeCapabilities(adapter),
source: "runtime",
};
} catch (error) {
return {
exchange,
exchangeName: getExchangeDisplayName(exchange),
symbol,
capabilities: staticCapabilities,
source: "static",
warning: extractMessage(error),
};
}
}
async function handleMarketTicker(
command: Extract<ParsedCliCommand, { kind: "market-ticker" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
const ticker = await waitForFirst<AsterTicker>(
(cb) => adapter.watchTicker(symbol, cb),
command.timeoutMs,
"market ticker"
);
return { exchange, symbol, ticker };
}
async function handleMarketDepth(
command: Extract<ParsedCliCommand, { kind: "market-depth" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
const depth = await waitForFirst<AsterDepth>(
(cb) => adapter.watchDepth(symbol, cb),
command.timeoutMs,
"market depth"
);
const levels = command.levels && command.levels > 0 ? Math.floor(command.levels) : undefined;
const boundedDepth = levels
? {
...depth,
bids: depth.bids.slice(0, levels),
asks: depth.asks.slice(0, levels),
}
: depth;
return { exchange, symbol, levels: levels ?? null, depth: boundedDepth };
}
async function handleMarketKline(
command: Extract<ParsedCliCommand, { kind: "market-kline" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
const klines = await waitForFirst<AsterKline[]>(
(cb) => adapter.watchKlines(symbol, command.interval, cb),
command.timeoutMs,
"market kline"
);
const limit = command.limit && command.limit > 0 ? Math.floor(command.limit) : undefined;
const data = limit ? klines.slice(-limit) : klines;
return { exchange, symbol, interval: command.interval, limit: limit ?? null, klines: data };
}
async function handleAccountSnapshot(
command: Extract<ParsedCliCommand, { kind: "account-snapshot" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
if (!adapter.queryAccountSnapshot) {
throw new CommandExecutionError(
"UNSUPPORTED",
EXIT_CODE_UNSUPPORTED,
`queryAccountSnapshot is not supported on exchange '${exchange}'`
);
}
const snapshot = await adapter.queryAccountSnapshot();
return { exchange, symbol, snapshot };
}
async function handlePositionList(
command: Extract<ParsedCliCommand, { kind: "position-list" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
if (!adapter.queryAccountSnapshot) {
throw new CommandExecutionError(
"UNSUPPORTED",
EXIT_CODE_UNSUPPORTED,
`queryAccountSnapshot is not supported on exchange '${exchange}'`
);
}
const snapshot = await adapter.queryAccountSnapshot();
const positions = snapshot?.positions ?? [];
const filtered = symbol ? positions.filter((position) => position.symbol === symbol) : positions;
return { exchange, symbol, positions: filtered };
}
async function handleOrderOpen(
command: Extract<ParsedCliCommand, { kind: "order-open" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol } = createAdapterContext(command, buildAdapterFromEnvFn);
if (!adapter.queryOpenOrders) {
throw new CommandExecutionError(
"UNSUPPORTED",
EXIT_CODE_UNSUPPORTED,
`queryOpenOrders is not supported on exchange '${exchange}'`
);
}
const orders = await adapter.queryOpenOrders();
const filtered = symbol ? orders.filter((order) => order.symbol === symbol) : orders;
return { exchange, symbol, orders: filtered };
}
async function handleOrderCreate(
command: Extract<ParsedCliCommand, { kind: "order-create" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol, dryRunAdapter } = createAdapterContext(command, buildAdapterFromEnvFn);
const execAdapter = dryRunAdapter ?? adapter;
const payload = command.payload;
const baseIntent = {
adapter: execAdapter,
symbol,
side: payload.side,
quantity: payload.quantity,
reduceOnly: payload.reduceOnly,
closePosition: payload.closePosition,
timeInForce: payload.timeInForce,
};
let order: AsterOrder;
switch (payload.type) {
case "limit":
order = await routeLimitOrder({
...baseIntent,
price: payload.price!,
slPrice: payload.slPrice,
tpPrice: payload.tpPrice,
});
break;
case "market":
order = await routeMarketOrder(baseIntent);
break;
case "stop":
order = await routeStopOrder({
...baseIntent,
stopPrice: payload.stopPrice!,
triggerType: payload.triggerType,
});
break;
case "trailing-stop":
order = await routeTrailingStopOrder({
...baseIntent,
activationPrice: payload.activationPrice!,
callbackRate: payload.callbackRate!,
});
break;
case "close":
order = await routeCloseOrder({
...baseIntent,
reduceOnly: payload.reduceOnly ?? true,
closePosition: payload.closePosition ?? true,
});
break;
default:
throw new CommandExecutionError("INVALID_ARGS", EXIT_CODE_INVALID_ARGS, "Unsupported order type");
}
return {
exchange,
symbol,
payload,
order,
dryRunActions: dryRunAdapter?.actions ?? [],
};
}
async function handleOrderCancel(
command: Extract<ParsedCliCommand, { kind: "order-cancel" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol, dryRunAdapter } = createAdapterContext(command, buildAdapterFromEnvFn);
const execAdapter = dryRunAdapter ?? adapter;
await execAdapter.cancelOrder({ symbol, orderId: command.orderId });
return {
exchange,
symbol,
orderId: command.orderId,
dryRunActions: dryRunAdapter?.actions ?? [],
};
}
async function handleOrderCancelAll(
command: Extract<ParsedCliCommand, { kind: "order-cancel-all" }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): Promise<unknown> {
const { adapter, exchange, symbol, dryRunAdapter } = createAdapterContext(command, buildAdapterFromEnvFn);
const execAdapter = dryRunAdapter ?? adapter;
let forced = false;
let forceResult: boolean | null = null;
if (execAdapter.forceCancelAllOrders) {
forced = true;
forceResult = await execAdapter.forceCancelAllOrders();
} else {
await execAdapter.cancelAllOrders({ symbol });
}
return {
exchange,
symbol,
forced,
forceResult,
dryRunActions: dryRunAdapter?.actions ?? [],
};
}
function createAdapterContext(
command: Extract<ParsedCliCommand, { kind: Exclude<ParsedCliCommand["kind"], "help" | "exchange-list"> }>,
buildAdapterFromEnvFn: typeof buildAdapterFromEnv
): {
exchange: SupportedExchangeId;
symbol: string;
adapter: ExchangeAdapter;
dryRunAdapter?: DryRunExchangeAdapter;
} {
const exchange = resolveEffectiveExchange(command.exchange);
const symbol = resolveEffectiveSymbol(command.symbol, exchange);
const adapter = createAdapter(buildAdapterFromEnvFn, exchange, symbol);
if (!command.dryRun) {
return { exchange, symbol, adapter };
}
const dryRunAdapter = new DryRunExchangeAdapter(adapter);
return { exchange, symbol, adapter, dryRunAdapter };
}
function createAdapter(
buildAdapterFromEnvFn: typeof buildAdapterFromEnv,
exchange: SupportedExchangeId,
symbol: string
): ExchangeAdapter {
return buildAdapterFromEnvFn({
exchangeId: exchange,
symbol,
});
}
function resolveEffectiveExchange(explicit?: SupportedExchangeId): SupportedExchangeId {
if (explicit) return explicit;
return resolveExchangeId();
}
function resolveEffectiveSymbol(explicit: string | undefined, exchange: SupportedExchangeId): string {
if (explicit && explicit.trim()) {
return explicit.trim();
}
return resolveSymbolFromEnv(exchange);
}
function runtimeCapabilities(adapter: ExchangeAdapter): unknown {
return {
trailingStops: adapter.supportsTrailingStops(),
fundingRate: typeof adapter.watchFundingRate === "function",
precision: typeof adapter.getPrecision === "function",
queryOpenOrders: typeof adapter.queryOpenOrders === "function",
queryAccountSnapshot: typeof adapter.queryAccountSnapshot === "function",
changeMarginMode: typeof adapter.changeMarginMode === "function",
forceCancelAllOrders: typeof adapter.forceCancelAllOrders === "function",
};
}
function successPayload(command: ParsedCliCommand, nowMs: number, data: unknown): CommandSuccessPayload {
return {
success: true,
command: command.kind,
exchange: command.exchange,
symbol: command.symbol,
dryRun: command.dryRun,
ts: new Date(nowMs).toISOString(),
data,
};
}
function failurePayload(
command: ParsedCliCommand,
nowMs: number,
error: CommandExecutionError
): CommandFailurePayload {
return {
success: false,
command: command.kind,
exchange: command.exchange,
symbol: command.symbol,
dryRun: command.dryRun,
ts: new Date(nowMs).toISOString(),
error: {
code: error.code,
message: error.message,
retryable: error.retryable,
details: error.details,
},
};
}
function mapToCommandExecutionError(error: unknown): CommandExecutionError {
if (error instanceof CommandExecutionError) {
return error;
}
const message = extractMessage(error);
const lower = message.toLowerCase();
if (lower.includes("timeout")) {
return new CommandExecutionError("TIMEOUT", EXIT_CODE_TIMEOUT, message, true);
}
if (lower.includes("unsupported") || lower.includes("not supported")) {
return new CommandExecutionError("UNSUPPORTED", EXIT_CODE_UNSUPPORTED, message);
}
if (lower.includes("missing") && lower.includes("environment")) {
return new CommandExecutionError("MISSING_ENV", EXIT_CODE_MISSING_ENV, message);
}
if (lower.includes("missing ") || lower.includes("required option")) {
return new CommandExecutionError("INVALID_ARGS", EXIT_CODE_INVALID_ARGS, message);
}
return new CommandExecutionError("EXCHANGE_ERROR", EXIT_CODE_EXCHANGE_ERROR, message, true);
}
function formatHumanSuccess(payload: CommandSuccessPayload): string {
const lines = [
`[OK] ${payload.command}`,
`time: ${payload.ts}`,
payload.exchange ? `exchange: ${payload.exchange}` : null,
payload.symbol ? `symbol: ${payload.symbol}` : null,
`dryRun: ${payload.dryRun ? "true" : "false"}`,
"",
safeJsonStringify(payload.data),
].filter(Boolean) as string[];
return lines.join("\n");
}
function formatHumanError(payload: CommandFailurePayload): string {
return [
`[ERROR] ${payload.command}`,
`time: ${payload.ts}`,
`code: ${payload.error.code}`,
`message: ${payload.error.message}`,
payload.error.retryable != null ? `retryable: ${payload.error.retryable ? "true" : "false"}` : null,
]
.filter(Boolean)
.join("\n");
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function waitForFirst<T>(
subscribe: (cb: (value: T) => void) => void,
timeoutMs: number,
context: string
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new CommandExecutionError(
"TIMEOUT",
EXIT_CODE_TIMEOUT,
`${context} timed out after ${timeoutMs}ms`,
true
)
);
}, timeoutMs);
try {
subscribe((value) => {
clearTimeout(timeout);
resolve(value);
});
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
}
async function withExchangeOverride<T>(
explicitExchange: SupportedExchangeId | undefined,
task: () => Promise<T>
): Promise<T> {
if (!explicitExchange) {
return task();
}
const prevExchange = process.env.EXCHANGE;
const prevTradeExchange = process.env.TRADE_EXCHANGE;
process.env.EXCHANGE = explicitExchange;
process.env.TRADE_EXCHANGE = explicitExchange;
try {
return await task();
} finally {
if (prevExchange == null) {
delete process.env.EXCHANGE;
} else {
process.env.EXCHANGE = prevExchange;
}
if (prevTradeExchange == null) {
delete process.env.TRADE_EXCHANGE;
} else {
process.env.TRADE_EXCHANGE = prevTradeExchange;
}
}
}
function extractMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
+527
View File
@@ -0,0 +1,527 @@
import { isSupportedExchangeId, type SupportedExchangeId } from "../exchanges/create-adapter";
import type { StrategyId } from "./args";
import type { CommandCommonOptions, OrderCreatePayload, OrderCreateType, ParsedCliCommand } from "./command-types";
const DEFAULT_TIMEOUT_MS = 25_000;
const ROOT_COMMANDS = new Set([
"help",
"doctor",
"exchange",
"market",
"account",
"position",
"order",
"strategy",
]);
const GLOBAL_OPTION_NAMES = new Set([
"exchange",
"symbol",
"json",
"dry-run",
"timeout",
"help",
]);
const SHORT_OPTION_ALIAS: Record<string, string> = {
d: "dry-run",
e: "exchange",
h: "help",
j: "json",
q: "silent",
s: "strategy",
t: "timeout",
};
const STRATEGY_VALUES = new Set<StrategyId>([
"trend",
"swing",
"guardian",
"maker",
"maker-points",
"offset-maker",
"liquidity-maker",
"basis",
"grid",
]);
export class CommandParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CommandParseError";
}
}
interface ParsedOptionBag {
options: Record<string, string | boolean>;
positionals: string[];
}
export function parseCommandArgv(argv: string[]): ParsedCliCommand | null {
if (!argv.length) return null;
const root = (argv[0] ?? "").trim().toLowerCase();
if (!root || root.startsWith("-")) return null;
if (!ROOT_COMMANDS.has(root)) return null;
if (root === "help") {
return parseHelpCommand(argv.slice(1));
}
if (root === "doctor") {
const bag = parseOptionBag(argv.slice(1));
assertAllowedOptions(bag.options, GLOBAL_OPTION_NAMES);
const common = parseCommonOptions(bag.options);
return { kind: "doctor", ...common };
}
if (argv.length < 2) {
throw new CommandParseError(`Missing action for command '${root}'`);
}
const action = (argv[1] ?? "").trim().toLowerCase();
const bag = parseOptionBag(argv.slice(2));
const common = parseCommonOptions(bag.options);
if (common.help) {
return {
kind: "help",
topic: `${root} ${action}`.trim(),
...common,
};
}
switch (root) {
case "exchange":
return parseExchangeCommand(action, bag.options, common);
case "market":
return parseMarketCommand(action, bag.options, common);
case "account":
return parseAccountCommand(action, bag.options, common);
case "position":
return parsePositionCommand(action, bag.options, common);
case "order":
return parseOrderCommand(action, bag.options, common);
case "strategy":
return parseStrategyCommand(action, bag, common);
default:
throw new CommandParseError(`Unsupported root command '${root}'`);
}
}
export function printCommandHelp(topic?: string): void {
if (!topic) {
// eslint-disable-next-line no-console
console.log([
"ritmex-bot command mode:",
" ritmex-bot doctor",
" ritmex-bot exchange list",
" ritmex-bot exchange capabilities [--exchange <id>]",
" ritmex-bot market ticker [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot market depth [--exchange <id>] [--symbol <symbol>] [--levels <n>]",
" ritmex-bot market kline --interval <interval> [--limit <n>] [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot account snapshot [--exchange <id>]",
" ritmex-bot position list [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot order open [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot order create --side <buy|sell> --type <limit|market|stop|trailing-stop|close> --quantity <n> [options]",
" ritmex-bot order cancel --order-id <id> [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot order cancel-all [--exchange <id>] [--symbol <symbol>]",
" ritmex-bot strategy run --strategy <id> [--exchange <id>] [--silent] [--dry-run]",
"",
"Global options:",
" --exchange, -e Exchange id",
" --symbol Trading symbol (passed through without normalization)",
" --json, -j JSON output",
" --dry-run, -d Simulate write operations",
" --timeout, -t Timeout in milliseconds (default 25000)",
" --help, -h Show command help",
"",
"Legacy mode remains available: bun run index.ts [--strategy ...] [--exchange ...]",
].join("\n"));
return;
}
// eslint-disable-next-line no-console
console.log(`ritmex-bot help: ${topic}`);
}
function parseHelpCommand(argv: string[]): ParsedCliCommand {
const bag = parseOptionBag(argv);
assertAllowedOptions(bag.options, GLOBAL_OPTION_NAMES);
const common = parseCommonOptions(bag.options);
const topic = bag.positionals.length > 0 ? bag.positionals.join(" ") : undefined;
return { kind: "help", topic, ...common };
}
function parseExchangeCommand(
action: string,
options: Record<string, string | boolean>,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
assertAllowedOptions(options, new Set([...GLOBAL_OPTION_NAMES]));
if (action === "list") return { kind: "exchange-list", ...common };
if (action === "capabilities") return { kind: "exchange-capabilities", ...common };
throw new CommandParseError(`Unsupported exchange action '${action}'`);
}
function parseMarketCommand(
action: string,
options: Record<string, string | boolean>,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
assertAllowedOptions(options, new Set([...GLOBAL_OPTION_NAMES, "levels", "interval", "limit"]));
if (action === "ticker") return { kind: "market-ticker", ...common };
if (action === "depth") {
const levels = readNumberOption(options, ["levels"]);
return { kind: "market-depth", levels, ...common };
}
if (action === "kline") {
const interval = requireStringOption(options, ["interval"], "Missing required option --interval");
const limit = readNumberOption(options, ["limit"]);
return { kind: "market-kline", interval, limit, ...common };
}
throw new CommandParseError(`Unsupported market action '${action}'`);
}
function parseAccountCommand(
action: string,
options: Record<string, string | boolean>,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
assertAllowedOptions(options, GLOBAL_OPTION_NAMES);
if (action === "snapshot" || action === "summary") {
return { kind: "account-snapshot", ...common };
}
throw new CommandParseError(`Unsupported account action '${action}'`);
}
function parsePositionCommand(
action: string,
options: Record<string, string | boolean>,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
assertAllowedOptions(options, GLOBAL_OPTION_NAMES);
if (action === "list") {
return { kind: "position-list", ...common };
}
throw new CommandParseError(`Unsupported position action '${action}'`);
}
function parseOrderCommand(
action: string,
options: Record<string, string | boolean>,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
if (action === "open") {
assertAllowedOptions(options, GLOBAL_OPTION_NAMES);
return { kind: "order-open", ...common };
}
if (action === "cancel") {
assertAllowedOptions(options, new Set([...GLOBAL_OPTION_NAMES, "order-id"]));
const orderId = requireStringOption(options, ["order-id"], "Missing required option --order-id");
return { kind: "order-cancel", orderId, ...common };
}
if (action === "cancel-all") {
assertAllowedOptions(options, GLOBAL_OPTION_NAMES);
return { kind: "order-cancel-all", ...common };
}
if (action === "create") {
assertAllowedOptions(
options,
new Set([
...GLOBAL_OPTION_NAMES,
"side",
"type",
"quantity",
"qty",
"price",
"stop-price",
"activation-price",
"callback-rate",
"time-in-force",
"reduce-only",
"close-position",
"trigger-type",
"sl-price",
"tp-price",
])
);
const payload = parseOrderCreatePayload(options);
return { kind: "order-create", payload, ...common };
}
throw new CommandParseError(`Unsupported order action '${action}'`);
}
function parseStrategyCommand(
action: string,
bag: ParsedOptionBag,
common: CommandCommonOptions & { help?: boolean }
): ParsedCliCommand {
if (action !== "run") {
throw new CommandParseError(`Unsupported strategy action '${action}'`);
}
assertAllowedOptions(bag.options, new Set([...GLOBAL_OPTION_NAMES, "strategy", "silent"]));
const strategyInput = readStringOption(bag.options, ["strategy"]) ?? bag.positionals[0];
if (!strategyInput) {
throw new CommandParseError("Missing required option --strategy for strategy run");
}
const strategy = normalizeStrategy(strategyInput);
const silent = readBooleanOption(bag.options, ["silent"], false);
return { kind: "strategy-run", strategy, silent, ...common };
}
function parseOrderCreatePayload(options: Record<string, string | boolean>): OrderCreatePayload {
const side = normalizeSide(requireStringOption(options, ["side"], "Missing required option --side"));
const type = normalizeOrderType(requireStringOption(options, ["type"], "Missing required option --type"));
const quantity = requireNumberOption(options, ["quantity", "qty"], "Missing required option --quantity/--qty");
const payload: OrderCreatePayload = { side, type, quantity };
if (type === "limit") {
payload.price = requireNumberOption(options, ["price"], "Missing required option --price for limit orders");
}
if (type === "stop") {
payload.stopPrice = requireNumberOption(options, ["stop-price"], "Missing required option --stop-price for stop orders");
}
if (type === "trailing-stop") {
payload.activationPrice = requireNumberOption(
options,
["activation-price"],
"Missing required option --activation-price for trailing-stop orders"
);
payload.callbackRate = requireNumberOption(
options,
["callback-rate"],
"Missing required option --callback-rate for trailing-stop orders"
);
}
payload.timeInForce = normalizeTimeInForce(readStringOption(options, ["time-in-force"]));
payload.reduceOnly = readOptionalBooleanOption(options, ["reduce-only"]);
payload.closePosition = readOptionalBooleanOption(options, ["close-position"]);
payload.triggerType = normalizeTriggerType(readStringOption(options, ["trigger-type"]));
payload.slPrice = readNumberOption(options, ["sl-price"]);
payload.tpPrice = readNumberOption(options, ["tp-price"]);
payload.price = payload.price ?? readNumberOption(options, ["price"]);
payload.stopPrice = payload.stopPrice ?? readNumberOption(options, ["stop-price"]);
payload.activationPrice = payload.activationPrice ?? readNumberOption(options, ["activation-price"]);
payload.callbackRate = payload.callbackRate ?? readNumberOption(options, ["callback-rate"]);
return payload;
}
function parseCommonOptions(options: Record<string, string | boolean>): CommandCommonOptions & { help?: boolean } {
const exchangeRaw = readStringOption(options, ["exchange"]);
const symbol = readStringOption(options, ["symbol"]);
const json = readBooleanOption(options, ["json"], false);
const dryRun = readBooleanOption(options, ["dry-run"], false);
const timeoutMs = readNumberOption(options, ["timeout"]) ?? DEFAULT_TIMEOUT_MS;
const help = readBooleanOption(options, ["help"], false);
return {
exchange: normalizeExchange(exchangeRaw),
symbol: symbol?.trim() || undefined,
json,
dryRun,
timeoutMs,
help,
};
}
function parseOptionBag(args: string[]): ParsedOptionBag {
const options: Record<string, string | boolean> = {};
const positionals: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const token = args[i];
if (!token) continue;
if (token === "--") {
positionals.push(...args.slice(i + 1));
break;
}
if (token.startsWith("--")) {
const withoutPrefix = token.slice(2);
if (!withoutPrefix) throw new CommandParseError("Invalid option '--'");
const eqIndex = withoutPrefix.indexOf("=");
const rawKey = eqIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, eqIndex);
const key = rawKey.trim().toLowerCase();
if (!key) throw new CommandParseError(`Invalid option '${token}'`);
if (eqIndex !== -1) {
options[key] = withoutPrefix.slice(eqIndex + 1);
continue;
}
const next = args[i + 1];
if (next && !next.startsWith("-")) {
options[key] = next;
i += 1;
} else {
options[key] = true;
}
continue;
}
if (token.startsWith("-")) {
const short = token.slice(1);
if (short.length !== 1) {
throw new CommandParseError(`Unsupported short option '${token}'`);
}
const alias = SHORT_OPTION_ALIAS[short];
if (!alias) {
throw new CommandParseError(`Unsupported short option '${token}'`);
}
const next = args[i + 1];
if (next && !next.startsWith("-") && expectsValue(alias)) {
options[alias] = next;
i += 1;
} else {
options[alias] = true;
}
continue;
}
positionals.push(token);
}
return { options, positionals };
}
function expectsValue(optionName: string): boolean {
return optionName === "exchange" || optionName === "strategy" || optionName === "timeout";
}
function assertAllowedOptions(
options: Record<string, string | boolean>,
allowed: ReadonlySet<string>
): void {
for (const key of Object.keys(options)) {
if (!allowed.has(key)) {
throw new CommandParseError(`Unsupported option '--${key}'`);
}
}
}
function readStringOption(options: Record<string, string | boolean>, names: string[]): string | undefined {
for (const name of names) {
const raw = options[name];
if (raw == null) continue;
if (typeof raw !== "string") {
throw new CommandParseError(`Option --${name} requires a value`);
}
const trimmed = raw.trim();
if (!trimmed) {
throw new CommandParseError(`Option --${name} cannot be empty`);
}
return trimmed;
}
return undefined;
}
function requireStringOption(
options: Record<string, string | boolean>,
names: string[],
errorMessage: string
): string {
const value = readStringOption(options, names);
if (!value) {
throw new CommandParseError(errorMessage);
}
return value;
}
function readBooleanOption(options: Record<string, string | boolean>, names: string[], fallback: boolean): boolean {
const value = readOptionalBooleanOption(options, names);
return value == null ? fallback : value;
}
function readOptionalBooleanOption(options: Record<string, string | boolean>, names: string[]): boolean | undefined {
for (const name of names) {
const raw = options[name];
if (raw == null) continue;
if (raw === true) return true;
if (typeof raw !== "string") {
throw new CommandParseError(`Option --${name} expects a boolean value`);
}
const normalized = raw.trim().toLowerCase();
if (!normalized) return true;
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
throw new CommandParseError(`Option --${name} expects a boolean value`);
}
return undefined;
}
function readNumberOption(options: Record<string, string | boolean>, names: string[]): number | undefined {
const value = readStringOption(options, names);
if (!value) return undefined;
const number = Number(value);
if (!Number.isFinite(number)) {
throw new CommandParseError(`Option --${names[0]} expects a numeric value`);
}
return number;
}
function requireNumberOption(
options: Record<string, string | boolean>,
names: string[],
errorMessage: string
): number {
const number = readNumberOption(options, names);
if (number == null) {
throw new CommandParseError(errorMessage);
}
return number;
}
function normalizeExchange(value: string | undefined): SupportedExchangeId | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "gravity" || normalized === "grav" || normalized === "grv") return "grvt";
if (normalized === "bnb") return "binance";
if (isSupportedExchangeId(normalized)) return normalized;
throw new CommandParseError(`Unsupported exchange '${value}'`);
}
function normalizeStrategy(value: string): StrategyId {
const normalized = value.trim().toLowerCase();
if (STRATEGY_VALUES.has(normalized as StrategyId)) {
return normalized as StrategyId;
}
if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") return "offset-maker";
if (normalized === "makerpoints" || normalized === "maker_points") return "maker-points";
if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity_maker") {
return "liquidity-maker";
}
throw new CommandParseError(`Unsupported strategy '${value}'`);
}
function normalizeSide(value: string): "BUY" | "SELL" {
const normalized = value.trim().toUpperCase();
if (normalized === "BUY" || normalized === "SELL") return normalized;
throw new CommandParseError(`Unsupported side '${value}', expected BUY or SELL`);
}
function normalizeOrderType(value: string): OrderCreateType {
const normalized = value.trim().toLowerCase();
if (normalized === "limit" || normalized === "market" || normalized === "stop" || normalized === "close") {
return normalized;
}
if (normalized === "trailing-stop" || normalized === "trailing_stop" || normalized === "trailingstop") {
return "trailing-stop";
}
throw new CommandParseError(
`Unsupported order type '${value}', expected limit|market|stop|trailing-stop|close`
);
}
function normalizeTimeInForce(value: string | undefined): "GTC" | "IOC" | "FOK" | "GTX" | undefined {
if (!value) return undefined;
const normalized = value.trim().toUpperCase();
if (normalized === "GTC" || normalized === "IOC" || normalized === "FOK" || normalized === "GTX") {
return normalized;
}
throw new CommandParseError(`Unsupported time in force '${value}'`);
}
function normalizeTriggerType(
value: string | undefined
): "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS" | undefined {
if (!value) return undefined;
const normalized = value.trim().toUpperCase();
if (normalized === "UNSPECIFIED" || normalized === "TAKE_PROFIT" || normalized === "STOP_LOSS") {
return normalized;
}
throw new CommandParseError(`Unsupported trigger type '${value}'`);
}
+86
View File
@@ -0,0 +1,86 @@
import type { SupportedExchangeId } from "../exchanges/create-adapter";
import type { StrategyId } from "./args";
export interface CommandCommonOptions {
exchange?: SupportedExchangeId;
symbol?: string;
json: boolean;
dryRun: boolean;
timeoutMs: number;
}
export type OrderCreateType = "limit" | "market" | "stop" | "trailing-stop" | "close";
export interface OrderCreatePayload {
side: "BUY" | "SELL";
type: OrderCreateType;
quantity: number;
price?: number;
stopPrice?: number;
activationPrice?: number;
callbackRate?: number;
timeInForce?: "GTC" | "IOC" | "FOK" | "GTX";
reduceOnly?: boolean;
closePosition?: boolean;
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
slPrice?: number;
tpPrice?: number;
}
export type ParsedCliCommand =
| ({ kind: "help"; topic?: string } & CommandCommonOptions)
| ({ kind: "doctor" } & CommandCommonOptions)
| ({ kind: "exchange-list" } & CommandCommonOptions)
| ({ kind: "exchange-capabilities" } & CommandCommonOptions)
| ({ kind: "market-ticker" } & CommandCommonOptions)
| ({ kind: "market-depth"; levels?: number } & CommandCommonOptions)
| ({ kind: "market-kline"; interval: string; limit?: number } & CommandCommonOptions)
| ({ kind: "account-snapshot" } & CommandCommonOptions)
| ({ kind: "position-list" } & CommandCommonOptions)
| ({ kind: "order-open" } & CommandCommonOptions)
| ({ kind: "order-create"; payload: OrderCreatePayload } & CommandCommonOptions)
| ({ kind: "order-cancel"; orderId: string } & CommandCommonOptions)
| ({ kind: "order-cancel-all" } & CommandCommonOptions)
| ({ kind: "strategy-run"; strategy: StrategyId; silent: boolean } & CommandCommonOptions);
export interface CommandErrorPayload {
code:
| "INVALID_ARGS"
| "MISSING_ENV"
| "UNSUPPORTED"
| "EXCHANGE_ERROR"
| "TIMEOUT"
| "RUNTIME_ERROR";
message: string;
retryable?: boolean;
details?: unknown;
}
export interface CommandSuccessPayload {
success: true;
command: ParsedCliCommand["kind"];
exchange?: SupportedExchangeId;
symbol?: string;
dryRun: boolean;
ts: string;
data: unknown;
warnings?: string[];
}
export interface CommandFailurePayload {
success: false;
command: ParsedCliCommand["kind"] | "unknown";
exchange?: SupportedExchangeId;
symbol?: string;
dryRun: boolean;
ts: string;
error: CommandErrorPayload;
}
export type CommandPayload = CommandSuccessPayload | CommandFailurePayload;
export interface CommandExecutionResult {
exitCode: number;
payload: CommandPayload;
forceExit: boolean;
}
+29 -12
View File
@@ -2,6 +2,7 @@ import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig,
import { getExchangeDisplayName, isBasisSupportedExchangeId, 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";
@@ -16,6 +17,7 @@ import type { StrategyId } from "./args";
interface RunnerOptions {
silent?: boolean;
dryRun?: boolean;
}
type StrategyRunner = (options: RunnerOptions) => Promise<void>;
@@ -43,12 +45,13 @@ export async function startStrategy(strategyId: StrategyId, options: RunnerOptio
const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
trend: async (opts) => {
const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -56,12 +59,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
swing: async (opts) => {
const config = swingConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -69,12 +73,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
guardian: async (opts) => {
const config = tradingConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -82,12 +87,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
maker: async (opts) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -99,12 +105,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
throw new Error("Maker Points strategy only supports the StandX exchange.");
}
const config = makerPointsConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -112,12 +119,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
"offset-maker": async (opts) => {
const config = makerConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -125,12 +133,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
"liquidity-maker": async (opts) => {
const config = liquidityMakerConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -144,12 +153,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
if (!isBasisSupportedExchangeId(exchangeId)) {
throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges");
}
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
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),
@@ -157,12 +167,13 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
},
grid: async (opts) => {
const config = gridConfig;
const adapter = createAdapterOrThrow(config.symbol);
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),
@@ -174,6 +185,7 @@ 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;
@@ -226,7 +238,8 @@ async function runEngine<
onUpdate(emitter);
engine.start();
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${silent ? "silent" : "interactive"}. Press Ctrl+C to exit.`);
const modeLabel = `${silent ? "silent" : "interactive"}${harness.dryRun ? "+dry-run" : ""}`;
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
const shutdown = (signal: NodeJS.Signals) => {
try {
@@ -251,8 +264,12 @@ async function runEngine<
});
}
function createAdapterOrThrow(symbol: string): ExchangeAdapter {
return buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
if (dryRun) {
return new DryRunExchangeAdapter(adapter);
}
return adapter;
}
type TradeLogEntry = { time: string; type: string; detail: string };