mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
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.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dev": "bun run index.ts",
|
"dev": "bun run index.ts",
|
||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
"test": "bun x vitest run",
|
"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",
|
"test:watch": "bun x vitest",
|
||||||
"start:trend:silent": "bun run index.ts --strategy trend --silent",
|
"start:trend:silent": "bun run index.ts --strategy trend --silent",
|
||||||
"start:maker:silent": "bun run index.ts --strategy maker --silent",
|
"start:maker:silent": "bun run index.ts --strategy maker --silent",
|
||||||
|
|||||||
+6
-12
@@ -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 type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado" | "standx" | "binance";
|
exchange?: SupportedExchangeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||||
@@ -82,16 +84,7 @@ function assignStrategy(options: CliOptions, raw: string): void {
|
|||||||
function assignExchange(options: CliOptions, raw: string): void {
|
function assignExchange(options: CliOptions, raw: string): void {
|
||||||
const normalized = raw.trim().toLowerCase();
|
const normalized = raw.trim().toLowerCase();
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
if (
|
if (SUPPORTED_EXCHANGE_IDS.includes(normalized as SupportedExchangeId)) {
|
||||||
normalized === "aster" ||
|
|
||||||
normalized === "grvt" ||
|
|
||||||
normalized === "lighter" ||
|
|
||||||
normalized === "backpack" ||
|
|
||||||
normalized === "paradex" ||
|
|
||||||
normalized === "nado" ||
|
|
||||||
normalized === "standx" ||
|
|
||||||
normalized === "binance"
|
|
||||||
) {
|
|
||||||
options.exchange = normalized as CliOptions["exchange"];
|
options.exchange = normalized as CliOptions["exchange"];
|
||||||
} else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") {
|
} else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") {
|
||||||
options.exchange = "grvt";
|
options.exchange = "grvt";
|
||||||
@@ -99,8 +92,9 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
|
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx|binance>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <${exchangeList}>] [--silent]\n\n` +
|
||||||
`Options:\n` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
|
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 type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
@@ -141,7 +141,7 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||||
}
|
}
|
||||||
const exchangeId = resolveExchangeId();
|
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");
|
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);
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
|
|||||||
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
|
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
|
||||||
import { BinanceExchangeAdapter, type BinanceCredentials } from "./binance/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 {
|
export interface ExchangeFactoryOptions {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
@@ -21,64 +39,70 @@ export interface ExchangeFactoryOptions {
|
|||||||
binance?: BinanceCredentials;
|
binance?: BinanceCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedExchangeId =
|
export type SupportedExchangeId = (typeof SUPPORTED_EXCHANGE_IDS)[number];
|
||||||
| "aster"
|
export type BasisSupportedExchangeId = (typeof BASIS_SUPPORTED_EXCHANGE_IDS)[number];
|
||||||
| "grvt"
|
|
||||||
| "lighter"
|
const EXCHANGE_DISPLAY_NAME: Record<SupportedExchangeId, string> = {
|
||||||
| "backpack"
|
aster: "AsterDex",
|
||||||
| "paradex"
|
grvt: "GRVT",
|
||||||
| "nado"
|
lighter: "Lighter",
|
||||||
| "standx"
|
backpack: "Backpack",
|
||||||
| "binance";
|
paradex: "Paradex",
|
||||||
|
nado: "Nado",
|
||||||
|
standx: "StandX",
|
||||||
|
binance: "Binance",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXCHANGE_ALIAS_MAP: Record<string, SupportedExchangeId> = {
|
||||||
|
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 {
|
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
||||||
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
|
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
|
||||||
.toString()
|
.toString()
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (fallback === "grvt") return "grvt";
|
return EXCHANGE_ALIAS_MAP[fallback] ?? "aster";
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExchangeDisplayName(id: SupportedExchangeId): string {
|
export function getExchangeDisplayName(id: SupportedExchangeId): string {
|
||||||
if (id === "grvt") return "GRVT";
|
return EXCHANGE_DISPLAY_NAME[id];
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExchangeAdapter(options: ExchangeFactoryOptions): ExchangeAdapter {
|
export function createExchangeAdapter(options: ExchangeFactoryOptions): ExchangeAdapter {
|
||||||
const id = resolveExchangeId(options.exchange);
|
const id = resolveExchangeId(options.exchange);
|
||||||
if (id === "grvt") {
|
switch (id) {
|
||||||
|
case "aster":
|
||||||
|
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
||||||
|
case "grvt":
|
||||||
return new GrvtExchangeAdapter({ ...options.grvt, symbol: options.symbol });
|
return new GrvtExchangeAdapter({ ...options.grvt, symbol: options.symbol });
|
||||||
}
|
case "lighter":
|
||||||
if (id === "lighter") {
|
|
||||||
return new LighterExchangeAdapter({ ...options.lighter, displaySymbol: options.symbol });
|
return new LighterExchangeAdapter({ ...options.lighter, displaySymbol: options.symbol });
|
||||||
}
|
case "backpack":
|
||||||
if (id === "backpack") {
|
|
||||||
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
|
return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol });
|
||||||
}
|
case "paradex":
|
||||||
if (id === "paradex") {
|
|
||||||
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
|
return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol });
|
||||||
}
|
case "nado":
|
||||||
if (id === "nado") {
|
|
||||||
return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol });
|
return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol });
|
||||||
}
|
case "standx":
|
||||||
if (id === "standx") {
|
|
||||||
return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol });
|
return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol });
|
||||||
}
|
case "binance":
|
||||||
if (id === "binance") {
|
|
||||||
return new BinanceExchangeAdapter({ ...options.binance, symbol: options.symbol });
|
return new BinanceExchangeAdapter({ ...options.binance, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ExchangeAdapter } from "./adapter";
|
import type { ExchangeAdapter } from "./adapter";
|
||||||
import type { AsterOrder } from "./types";
|
import type { AsterOrder } from "./types";
|
||||||
|
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "./create-adapter";
|
||||||
import type {
|
import type {
|
||||||
BaseOrderIntent,
|
BaseOrderIntent,
|
||||||
ClosePositionIntent,
|
ClosePositionIntent,
|
||||||
@@ -17,7 +18,7 @@ import * as nadoOrders from "./nado/order";
|
|||||||
import * as standxOrders from "./standx/order";
|
import * as standxOrders from "./standx/order";
|
||||||
import * as binanceOrders from "./binance/order";
|
import * as binanceOrders from "./binance/order";
|
||||||
|
|
||||||
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado" | "standx" | "binance";
|
type ExchangeKey = SupportedExchangeId;
|
||||||
|
|
||||||
interface ExchangeOrderHandlers {
|
interface ExchangeOrderHandlers {
|
||||||
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
|
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
|
||||||
@@ -86,16 +87,7 @@ const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const knownExchanges: ExchangeKey[] = [
|
const knownExchanges: ExchangeKey[] = [...SUPPORTED_EXCHANGE_IDS];
|
||||||
"aster",
|
|
||||||
"backpack",
|
|
||||||
"grvt",
|
|
||||||
"lighter",
|
|
||||||
"paradex",
|
|
||||||
"nado",
|
|
||||||
"standx",
|
|
||||||
"binance",
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeExchangeId(value: string | undefined | null): string | undefined {
|
function normalizeExchangeId(value: string | undefined | null): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
@@ -107,7 +99,7 @@ function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey {
|
|||||||
const candidates = [fromEnv, normalizeExchangeId(adapter.id)];
|
const candidates = [fromEnv, normalizeExchangeId(adapter.id)];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) continue;
|
if (!candidate) continue;
|
||||||
if ((knownExchanges as string[]).includes(candidate)) {
|
if (knownExchanges.includes(candidate as ExchangeKey)) {
|
||||||
return candidate as ExchangeKey;
|
return candidate as ExchangeKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,42 +19,38 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt
|
|||||||
const id = resolveExchangeId(options.exchangeId);
|
const id = resolveExchangeId(options.exchangeId);
|
||||||
const symbol = options.symbol;
|
const symbol = options.symbol;
|
||||||
|
|
||||||
if (id === "aster") {
|
switch (id) {
|
||||||
|
case "aster": {
|
||||||
const credentials = resolveAsterCredentials();
|
const credentials = resolveAsterCredentials();
|
||||||
return createExchangeAdapter({ exchange: id, symbol, aster: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, aster: credentials });
|
||||||
}
|
}
|
||||||
|
case "grvt":
|
||||||
if (id === "lighter") {
|
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
|
||||||
|
case "lighter": {
|
||||||
const credentials = resolveLighterCredentials(symbol);
|
const credentials = resolveLighterCredentials(symbol);
|
||||||
return createExchangeAdapter({ exchange: id, symbol, lighter: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, lighter: credentials });
|
||||||
}
|
}
|
||||||
|
case "backpack": {
|
||||||
if (id === "backpack") {
|
|
||||||
const credentials = resolveBackpackCredentials(symbol);
|
const credentials = resolveBackpackCredentials(symbol);
|
||||||
return createExchangeAdapter({ exchange: id, symbol, backpack: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, backpack: credentials });
|
||||||
}
|
}
|
||||||
|
case "paradex": {
|
||||||
if (id === "paradex") {
|
|
||||||
const credentials = resolveParadexCredentials();
|
const credentials = resolveParadexCredentials();
|
||||||
return createExchangeAdapter({ exchange: id, symbol, paradex: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, paradex: credentials });
|
||||||
}
|
}
|
||||||
|
case "nado": {
|
||||||
if (id === "nado") {
|
|
||||||
const credentials = resolveNadoCredentials(symbol);
|
const credentials = resolveNadoCredentials(symbol);
|
||||||
return createExchangeAdapter({ exchange: id, symbol, nado: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, nado: credentials });
|
||||||
}
|
}
|
||||||
|
case "standx": {
|
||||||
if (id === "standx") {
|
|
||||||
const credentials = resolveStandxCredentials(symbol);
|
const credentials = resolveStandxCredentials(symbol);
|
||||||
return createExchangeAdapter({ exchange: id, symbol, standx: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, standx: credentials });
|
||||||
}
|
}
|
||||||
|
case "binance": {
|
||||||
if (id === "binance") {
|
|
||||||
const credentials = resolveBinanceCredentials(symbol);
|
const credentials = resolveBinanceCredentials(symbol);
|
||||||
return createExchangeAdapter({ exchange: id, symbol, binance: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, binance: credentials });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAsterCredentials(): AsterCredentials {
|
function resolveAsterCredentials(): AsterCredentials {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { basisConfig } from "../config";
|
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 { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
@@ -31,7 +31,7 @@ export function BasisApp({ onExit }: BasisAppProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx" && exchangeId !== "binance") {
|
if (!isBasisSupportedExchangeId(exchangeId)) {
|
||||||
setError(new Error(t("basis.onlyAster")));
|
setError(new Error(t("basis.onlyAster")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SupportedExchangeId>(["aster", "binance"]);
|
||||||
|
const REQUIRED_ENV_BY_EXCHANGE: Record<SupportedExchangeId, Record<string, string>> = {
|
||||||
|
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<AsterOrder> {
|
||||||
|
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<void> {}
|
||||||
|
|
||||||
|
async cancelOrders(_params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {}
|
||||||
|
|
||||||
|
async cancelAllOrders(_params: { symbol: string }): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user