mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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:
+6
-1
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}'`);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user