From 27abeb9fa4096b97c065331d49c32d547de26527 Mon Sep 17 00:00:00 2001 From: discountry Date: Fri, 27 Feb 2026 11:32:20 +0800 Subject: [PATCH] Enhance exchange support and testing framework - Added a new test suite for exchange contracts to ensure consistency and functionality across supported exchanges. - Refactored exchange ID handling to utilize a centralized list of supported exchanges, improving maintainability. - Updated CLI argument parsing and help documentation to reflect the new exchange structure. - Introduced utility functions for validating supported exchanges and their display names. - Enhanced the BasisApp and strategy runner to leverage the new exchange validation logic. - Added a new test command for running exchange-related tests. --- package.json | 1 + src/cli/args.ts | 18 +- src/cli/strategy-runner.ts | 4 +- src/exchanges/create-adapter.ts | 116 +++++++----- src/exchanges/order-router.ts | 16 +- src/exchanges/resolve-from-env.ts | 66 +++---- src/ui/BasisApp.tsx | 4 +- tests/exchange-contract-suite.test.ts | 260 ++++++++++++++++++++++++++ 8 files changed, 376 insertions(+), 109 deletions(-) create mode 100644 tests/exchange-contract-suite.test.ts diff --git a/package.json b/package.json index 5b7773d..c6e051f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "bun run index.ts", "start": "bun run index.ts", "test": "bun x vitest run", + "test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts tests/basis-arb-engine.test.ts", "test:watch": "bun x vitest", "start:trend:silent": "bun run index.ts --strategy trend --silent", "start:maker:silent": "bun run index.ts --strategy maker --silent", diff --git a/src/cli/args.ts b/src/cli/args.ts index f002f3d..42c5ffd 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,10 +1,12 @@ +import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter"; + export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; silent: boolean; help: boolean; - exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado" | "standx" | "binance"; + exchange?: SupportedExchangeId; } const STRATEGY_VALUES = new Set([ @@ -82,16 +84,7 @@ function assignStrategy(options: CliOptions, raw: string): void { function assignExchange(options: CliOptions, raw: string): void { const normalized = raw.trim().toLowerCase(); if (!normalized) return; - if ( - normalized === "aster" || - normalized === "grvt" || - normalized === "lighter" || - normalized === "backpack" || - normalized === "paradex" || - normalized === "nado" || - normalized === "standx" || - normalized === "binance" - ) { + if (SUPPORTED_EXCHANGE_IDS.includes(normalized as SupportedExchangeId)) { options.exchange = normalized as CliOptions["exchange"]; } else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") { options.exchange = "grvt"; @@ -99,8 +92,9 @@ function assignExchange(options: CliOptions, raw: string): void { } export function printCliHelp(): void { + const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|"); // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange <${exchangeList}>] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index affe404..32e2543 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,5 +1,5 @@ import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; @@ -141,7 +141,7 @@ const STRATEGY_FACTORIES: Record = { throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it."); } const exchangeId = resolveExchangeId(); - if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx" && exchangeId !== "binance") { + if (!isBasisSupportedExchangeId(exchangeId)) { throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges"); } const adapter = createAdapterOrThrow(basisConfig.futuresSymbol); diff --git a/src/exchanges/create-adapter.ts b/src/exchanges/create-adapter.ts index b38ed45..f7980e2 100644 --- a/src/exchanges/create-adapter.ts +++ b/src/exchanges/create-adapter.ts @@ -8,6 +8,24 @@ import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter"; import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter"; import { BinanceExchangeAdapter, type BinanceCredentials } from "./binance/adapter"; +export const SUPPORTED_EXCHANGE_IDS = [ + "aster", + "grvt", + "lighter", + "backpack", + "paradex", + "nado", + "standx", + "binance", +] as const; + +export const BASIS_SUPPORTED_EXCHANGE_IDS = [ + "aster", + "nado", + "standx", + "binance", +] as const; + export interface ExchangeFactoryOptions { symbol: string; exchange?: string; @@ -21,64 +39,70 @@ export interface ExchangeFactoryOptions { binance?: BinanceCredentials; } -export type SupportedExchangeId = - | "aster" - | "grvt" - | "lighter" - | "backpack" - | "paradex" - | "nado" - | "standx" - | "binance"; +export type SupportedExchangeId = (typeof SUPPORTED_EXCHANGE_IDS)[number]; +export type BasisSupportedExchangeId = (typeof BASIS_SUPPORTED_EXCHANGE_IDS)[number]; + +const EXCHANGE_DISPLAY_NAME: Record = { + aster: "AsterDex", + grvt: "GRVT", + lighter: "Lighter", + backpack: "Backpack", + paradex: "Paradex", + nado: "Nado", + standx: "StandX", + binance: "Binance", +}; + +const EXCHANGE_ALIAS_MAP: Record = { + aster: "aster", + grvt: "grvt", + lighter: "lighter", + backpack: "backpack", + paradex: "paradex", + nado: "nado", + standx: "standx", + binance: "binance", + bnb: "binance", +}; + +export function isSupportedExchangeId(value: string): value is SupportedExchangeId { + return SUPPORTED_EXCHANGE_IDS.includes(value as SupportedExchangeId); +} + +export function isBasisSupportedExchangeId(value: string): value is BasisSupportedExchangeId { + return BASIS_SUPPORTED_EXCHANGE_IDS.includes(value as BasisSupportedExchangeId); +} export function resolveExchangeId(value?: string | null): SupportedExchangeId { const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster") .toString() .trim() .toLowerCase(); - if (fallback === "grvt") return "grvt"; - if (fallback === "lighter") return "lighter"; - if (fallback === "backpack") return "backpack"; - if (fallback === "paradex") return "paradex"; - if (fallback === "nado") return "nado"; - if (fallback === "standx") return "standx"; - if (fallback === "binance" || fallback === "bnb") return "binance"; - return "aster"; + return EXCHANGE_ALIAS_MAP[fallback] ?? "aster"; } export function getExchangeDisplayName(id: SupportedExchangeId): string { - if (id === "grvt") return "GRVT"; - if (id === "lighter") return "Lighter"; - if (id === "backpack") return "Backpack"; - if (id === "paradex") return "Paradex"; - if (id === "nado") return "Nado"; - if (id === "standx") return "StandX"; - if (id === "binance") return "Binance"; - return "AsterDex"; + return EXCHANGE_DISPLAY_NAME[id]; } export function createExchangeAdapter(options: ExchangeFactoryOptions): ExchangeAdapter { const id = resolveExchangeId(options.exchange); - if (id === "grvt") { - return new GrvtExchangeAdapter({ ...options.grvt, symbol: options.symbol }); + switch (id) { + case "aster": + return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol }); + case "grvt": + return new GrvtExchangeAdapter({ ...options.grvt, symbol: options.symbol }); + case "lighter": + return new LighterExchangeAdapter({ ...options.lighter, displaySymbol: options.symbol }); + case "backpack": + return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol }); + case "paradex": + return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol }); + case "nado": + return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol }); + case "standx": + return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol }); + case "binance": + return new BinanceExchangeAdapter({ ...options.binance, symbol: options.symbol }); } - if (id === "lighter") { - return new LighterExchangeAdapter({ ...options.lighter, displaySymbol: options.symbol }); - } - if (id === "backpack") { - return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol }); - } - if (id === "paradex") { - return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol }); - } - if (id === "nado") { - return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol }); - } - if (id === "standx") { - return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol }); - } - if (id === "binance") { - return new BinanceExchangeAdapter({ ...options.binance, symbol: options.symbol }); - } - return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol }); } diff --git a/src/exchanges/order-router.ts b/src/exchanges/order-router.ts index 36df132..714ccb7 100644 --- a/src/exchanges/order-router.ts +++ b/src/exchanges/order-router.ts @@ -1,5 +1,6 @@ import type { ExchangeAdapter } from "./adapter"; import type { AsterOrder } from "./types"; +import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "./create-adapter"; import type { BaseOrderIntent, ClosePositionIntent, @@ -17,7 +18,7 @@ import * as nadoOrders from "./nado/order"; import * as standxOrders from "./standx/order"; import * as binanceOrders from "./binance/order"; -type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado" | "standx" | "binance"; +type ExchangeKey = SupportedExchangeId; interface ExchangeOrderHandlers { limit(intent: LimitOrderIntent): Promise; @@ -86,16 +87,7 @@ const handlerMap: Record = { }, }; -const knownExchanges: ExchangeKey[] = [ - "aster", - "backpack", - "grvt", - "lighter", - "paradex", - "nado", - "standx", - "binance", -]; +const knownExchanges: ExchangeKey[] = [...SUPPORTED_EXCHANGE_IDS]; function normalizeExchangeId(value: string | undefined | null): string | undefined { if (!value) return undefined; @@ -107,7 +99,7 @@ function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey { const candidates = [fromEnv, normalizeExchangeId(adapter.id)]; for (const candidate of candidates) { if (!candidate) continue; - if ((knownExchanges as string[]).includes(candidate)) { + if (knownExchanges.includes(candidate as ExchangeKey)) { return candidate as ExchangeKey; } } diff --git a/src/exchanges/resolve-from-env.ts b/src/exchanges/resolve-from-env.ts index 23c71bd..7d88a93 100644 --- a/src/exchanges/resolve-from-env.ts +++ b/src/exchanges/resolve-from-env.ts @@ -19,42 +19,38 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt const id = resolveExchangeId(options.exchangeId); const symbol = options.symbol; - if (id === "aster") { - const credentials = resolveAsterCredentials(); - return createExchangeAdapter({ exchange: id, symbol, aster: credentials }); + switch (id) { + case "aster": { + const credentials = resolveAsterCredentials(); + return createExchangeAdapter({ exchange: id, symbol, aster: credentials }); + } + case "grvt": + return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } }); + case "lighter": { + const credentials = resolveLighterCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, lighter: credentials }); + } + case "backpack": { + const credentials = resolveBackpackCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, backpack: credentials }); + } + case "paradex": { + const credentials = resolveParadexCredentials(); + return createExchangeAdapter({ exchange: id, symbol, paradex: credentials }); + } + case "nado": { + const credentials = resolveNadoCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, nado: credentials }); + } + case "standx": { + const credentials = resolveStandxCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, standx: credentials }); + } + case "binance": { + const credentials = resolveBinanceCredentials(symbol); + return createExchangeAdapter({ exchange: id, symbol, binance: credentials }); + } } - - if (id === "lighter") { - const credentials = resolveLighterCredentials(symbol); - return createExchangeAdapter({ exchange: id, symbol, lighter: credentials }); - } - - if (id === "backpack") { - const credentials = resolveBackpackCredentials(symbol); - return createExchangeAdapter({ exchange: id, symbol, backpack: credentials }); - } - - if (id === "paradex") { - const credentials = resolveParadexCredentials(); - return createExchangeAdapter({ exchange: id, symbol, paradex: credentials }); - } - - if (id === "nado") { - const credentials = resolveNadoCredentials(symbol); - return createExchangeAdapter({ exchange: id, symbol, nado: credentials }); - } - - if (id === "standx") { - const credentials = resolveStandxCredentials(symbol); - return createExchangeAdapter({ exchange: id, symbol, standx: credentials }); - } - - if (id === "binance") { - const credentials = resolveBinanceCredentials(symbol); - return createExchangeAdapter({ exchange: id, symbol, binance: credentials }); - } - - return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } }); } function resolveAsterCredentials(): AsterCredentials { diff --git a/src/ui/BasisApp.tsx b/src/ui/BasisApp.tsx index 1d2ba5d..28c253b 100644 --- a/src/ui/BasisApp.tsx +++ b/src/ui/BasisApp.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { basisConfig } from "../config"; -import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; import { formatNumber } from "../utils/format"; @@ -31,7 +31,7 @@ export function BasisApp({ onExit }: BasisAppProps) { ); useEffect(() => { - if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx" && exchangeId !== "binance") { + if (!isBasisSupportedExchangeId(exchangeId)) { setError(new Error(t("basis.onlyAster"))); return; } diff --git a/tests/exchange-contract-suite.test.ts b/tests/exchange-contract-suite.test.ts new file mode 100644 index 0000000..4b0f5b2 --- /dev/null +++ b/tests/exchange-contract-suite.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BASIS_SUPPORTED_EXCHANGE_IDS, + SUPPORTED_EXCHANGE_IDS, + getExchangeDisplayName, + resolveExchangeId, + type SupportedExchangeId, +} from "../src/exchanges/create-adapter"; +import { parseCliArgs, printCliHelp } from "../src/cli/args"; +import { resolveSymbolFromEnv } from "../src/config"; +import { + routeCloseOrder, + routeLimitOrder, + routeMarketOrder, + routeStopOrder, + routeTrailingStopOrder, +} from "../src/exchanges/order-router"; +import { buildAdapterFromEnv } from "../src/exchanges/resolve-from-env"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { + AsterAccountSnapshot, + AsterDepth, + AsterKline, + AsterOrder, + AsterTicker, + CreateOrderParams, +} from "../src/exchanges/types"; + +const ORIGINAL_ENV = { ...process.env }; + +const TRAILING_SUPPORTED_EXCHANGES = new Set(["aster", "binance"]); +const REQUIRED_ENV_BY_EXCHANGE: Record> = { + aster: { + ASTER_API_KEY: "aster-key", + ASTER_API_SECRET: "aster-secret", + }, + grvt: { + GRVT_API_KEY: "grvt-key", + GRVT_API_SECRET: `0x${"1".repeat(64)}`, + GRVT_SUB_ACCOUNT_ID: "sub-account", + GRVT_INSTRUMENT: "BTC_USDT_Perp", + GRVT_SYMBOL: "BTCUSDT", + }, + lighter: { + LIGHTER_ACCOUNT_INDEX: "1", + LIGHTER_API_PRIVATE_KEY: "lighter-private-key", + LIGHTER_API_KEY_INDEX: "0", + }, + backpack: { + BACKPACK_API_KEY: "backpack-key", + BACKPACK_API_SECRET: "backpack-secret", + }, + paradex: { + PARADEX_PRIVATE_KEY: `0x${"2".repeat(64)}`, + PARADEX_WALLET_ADDRESS: `0x${"3".repeat(40)}`, + }, + nado: { + NADO_SIGNER_PRIVATE_KEY: `0x${"4".repeat(64)}`, + NADO_SUBACCOUNT_OWNER: `0x${"5".repeat(40)}`, + }, + standx: { + STANDX_TOKEN: "standx-token", + }, + binance: { + BINANCE_API_KEY: "binance-key", + BINANCE_API_SECRET: "binance-secret", + }, +}; + +class RecorderAdapter implements ExchangeAdapter { + readonly id: SupportedExchangeId; + public lastCreateOrderParams: CreateOrderParams | null = null; + + constructor(id: SupportedExchangeId) { + this.id = id; + } + + supportsTrailingStops(): boolean { + return TRAILING_SUPPORTED_EXCHANGES.has(this.id); + } + + watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {} + + watchOrders(_cb: (orders: AsterOrder[]) => void): void {} + + watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {} + + watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {} + + watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {} + + async createOrder(params: CreateOrderParams): Promise { + this.lastCreateOrderParams = params; + return { + orderId: 1, + clientOrderId: "test-client-order", + symbol: params.symbol, + side: params.side, + type: params.type, + status: "NEW", + price: String(params.price ?? 0), + origQty: String(params.quantity ?? 0), + executedQty: "0", + stopPrice: String(params.stopPrice ?? 0), + time: Date.now(), + updateTime: Date.now(), + reduceOnly: params.reduceOnly === "true", + closePosition: params.closePosition === "true", + timeInForce: params.timeInForce, + }; + } + + async cancelOrder(_params: { symbol: string; orderId: number | string }): Promise {} + + async cancelOrders(_params: { symbol: string; orderIdList: Array }): Promise {} + + async cancelAllOrders(_params: { symbol: string }): Promise {} +} + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("exchange contract suite", () => { + it("keeps exchange registry consistent and case-insensitive", () => { + expect(new Set(SUPPORTED_EXCHANGE_IDS).size).toBe(SUPPORTED_EXCHANGE_IDS.length); + expect(new Set(BASIS_SUPPORTED_EXCHANGE_IDS).size).toBe(BASIS_SUPPORTED_EXCHANGE_IDS.length); + + for (const id of BASIS_SUPPORTED_EXCHANGE_IDS) { + expect(SUPPORTED_EXCHANGE_IDS).toContain(id); + } + + for (const id of SUPPORTED_EXCHANGE_IDS) { + expect(resolveExchangeId(id.toUpperCase())).toBe(id); + expect(getExchangeDisplayName(id)).toBeTruthy(); + } + }); + + it("accepts every supported exchange from CLI and documents them in help output", () => { + for (const id of SUPPORTED_EXCHANGE_IDS) { + expect(parseCliArgs(["--exchange", id]).exchange).toBe(id); + expect(parseCliArgs(["--exchange", id.toUpperCase()]).exchange).toBe(id); + } + + const spy = vi.spyOn(console, "log").mockImplementation(() => undefined); + printCliHelp(); + const output = spy.mock.calls.map((entry) => String(entry[0] ?? "")).join("\n"); + for (const id of SUPPORTED_EXCHANGE_IDS) { + expect(output).toContain(id); + } + spy.mockRestore(); + }); + + it("provides a default symbol fallback for every supported exchange", () => { + for (const id of SUPPORTED_EXCHANGE_IDS) { + const symbol = resolveSymbolFromEnv(id); + expect(typeof symbol).toBe("string"); + expect(symbol.length).toBeGreaterThan(0); + } + }); + + it("builds the requested adapter id for every supported exchange", () => { + for (const id of SUPPORTED_EXCHANGE_IDS) { + process.env = { ...ORIGINAL_ENV, ...REQUIRED_ENV_BY_EXCHANGE[id] }; + const adapter = buildAdapterFromEnv({ exchangeId: id, symbol: "BTCUSDT" }); + expect(adapter.id).toBe(id); + expect(typeof adapter.supportsTrailingStops()).toBe("boolean"); + expect(typeof adapter.watchAccount).toBe("function"); + expect(typeof adapter.watchOrders).toBe("function"); + expect(typeof adapter.watchDepth).toBe("function"); + expect(typeof adapter.watchTicker).toBe("function"); + expect(typeof adapter.watchKlines).toBe("function"); + expect(typeof adapter.createOrder).toBe("function"); + expect(typeof adapter.cancelOrder).toBe("function"); + expect(typeof adapter.cancelOrders).toBe("function"); + expect(typeof adapter.cancelAllOrders).toBe("function"); + } + }); + + it("fails fast when required credentials are missing", () => { + for (const id of SUPPORTED_EXCHANGE_IDS) { + process.env = { ...ORIGINAL_ENV }; + expect(() => buildAdapterFromEnv({ exchangeId: id, symbol: "BTCUSDT" })).toThrow(); + } + }); + + it("routes core order intents for every supported exchange", async () => { + delete process.env.EXCHANGE; + delete process.env.TRADE_EXCHANGE; + + for (const id of SUPPORTED_EXCHANGE_IDS) { + const adapter = new RecorderAdapter(id); + + await routeLimitOrder({ + adapter, + symbol: "BTCUSDT", + side: "BUY", + quantity: 0.01, + price: 100_000, + }); + expect(adapter.lastCreateOrderParams?.type).toBe("LIMIT"); + + await routeMarketOrder({ + adapter, + symbol: "BTCUSDT", + side: "SELL", + quantity: 0.01, + }); + expect(adapter.lastCreateOrderParams?.type).toBe("MARKET"); + + await routeStopOrder({ + adapter, + symbol: "BTCUSDT", + side: "SELL", + quantity: 0.01, + stopPrice: 99_000, + }); + expect(adapter.lastCreateOrderParams?.type).toBe("STOP_MARKET"); + + await routeCloseOrder({ + adapter, + symbol: "BTCUSDT", + side: "SELL", + quantity: 0.01, + reduceOnly: true, + closePosition: true, + }); + expect(adapter.lastCreateOrderParams?.type).toBe("MARKET"); + expect(adapter.lastCreateOrderParams?.reduceOnly).toBe("true"); + } + }); + + it("enforces trailing-stop capability via exchange-specific handlers", async () => { + delete process.env.EXCHANGE; + delete process.env.TRADE_EXCHANGE; + + for (const id of SUPPORTED_EXCHANGE_IDS) { + const adapter = new RecorderAdapter(id); + const intent = { + adapter, + symbol: "BTCUSDT", + side: "SELL" as const, + quantity: 0.01, + activationPrice: 101_000, + callbackRate: 0.2, + }; + + if (TRAILING_SUPPORTED_EXCHANGES.has(id)) { + await expect(routeTrailingStopOrder(intent)).resolves.toMatchObject({ type: "TRAILING_STOP_MARKET" }); + expect(adapter.lastCreateOrderParams?.type).toBe("TRAILING_STOP_MARKET"); + } else { + await expect(routeTrailingStopOrder(intent)).rejects.toThrow(/does not support trailing stop/i); + } + } + }); +});