From cfd5f4309dc7d486b8221667cd7823400a53e9ed Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Oct 2025 18:06:23 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Paradex=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E9=80=82=E9=85=8D=E5=99=A8=E6=9E=84=E5=BB=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 26 +- src/cli/strategy-runner.ts | 54 +- src/config.ts | 38 +- src/core/order-coordinator.ts | 2 + src/exchanges/create-adapter.ts | 9 +- src/exchanges/paradex/adapter.ts | 210 ++++++ src/exchanges/paradex/gateway.ts | 995 ++++++++++++++++++++++++++++ src/exchanges/resolve-from-env.ts | 137 ++++ src/strategy/maker-engine.ts | 163 ++++- src/strategy/offset-maker-engine.ts | 6 +- src/strategy/trend-engine.ts | 2 + src/ui/MakerApp.tsx | 68 +- src/ui/OffsetMakerApp.tsx | 28 +- src/ui/TrendApp.tsx | 28 +- src/utils/errors.ts | 19 +- src/utils/strategy.ts | 14 +- tests/config.test.ts | 46 ++ tests/exchange-factory.test.ts | 13 + 18 files changed, 1681 insertions(+), 177 deletions(-) create mode 100644 src/exchanges/paradex/adapter.ts create mode 100644 src/exchanges/paradex/gateway.ts create mode 100644 src/exchanges/resolve-from-env.ts create mode 100644 tests/config.test.ts diff --git a/.env.example b/.env.example index 839cb70..77a13fa 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Exchange selection -EXCHANGE=aster # Pick aster (default) or grvt +EXCHANGE=aster # Pick aster (default) or grvt/lighter/backpack/paradex # Aster API credentials ASTER_API_KEY= @@ -77,4 +77,26 @@ BACKPACK_SANDBOX=false BACKPACK_SYMBOL=BTC_USD_PERP # Enable verbose adapter logging: set to "1" or "true" -BACKPACK_DEBUG=false \ No newline at end of file +BACKPACK_DEBUG=false + +# Paradex exchange configuration +# Provide the EVM private key & wallet address for onboarded accounts. +# When EXCHANGE=paradex these values are used automatically. + +PARADEX_PRIVATE_KEY= +PARADEX_WALLET_ADDRESS= + +# Symbol defaults to TRADE_SYMBOL if omitted. Use ccxt unified format like BTC-USD-PERP. +# PARADEX_SYMBOL=BTC-USD-PERP + +# Enable testnet endpoints by setting to "true"; defaults to false (mainnet). +# PARADEX_SANDBOX=false + +# Force disabling ccxt.pro websocket usage by setting to "false" (pro is preferred when installed). +# PARADEX_USE_PRO=true + +# Optional reconnect delay override (milliseconds, e.g., 2000). Leave blank for default. +# PARADEX_RECONNECT_DELAY_MS= + +# Enable verbose adapter logging: set to "1" or "true" +# PARADEX_DEBUG=false diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index 424827e..9274e8f 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,10 +1,7 @@ import { makerConfig, tradingConfig } from "../config"; -import { - createExchangeAdapter, - getExchangeDisplayName, - resolveExchangeId, -} from "../exchanges/create-adapter"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot, @@ -147,52 +144,7 @@ async function runEngine = { + aster: { envKeys: ["ASTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" }, + grvt: { envKeys: ["GRVT_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" }, + lighter: { envKeys: ["LIGHTER_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDT" }, + backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" }, + paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" }, +}; + +export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string { + const exchangeId = explicitExchangeId + ? resolveExchangeId(explicitExchangeId) + : resolveExchangeId(); + const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId]; + for (const key of envKeys) { + const value = process.env[key]; + if (value && value.trim()) { + return value.trim(); + } + } + return fallback; +} + function parseNumber(value: string | undefined, fallback: number): number { if (!value) return fallback; const next = Number(value); @@ -39,7 +73,7 @@ function parseNumber(value: string | undefined, fallback: number): number { } export const tradingConfig: TradingConfig = { - symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT", + symbol: resolveSymbolFromEnv(), tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001), lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03), trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2), @@ -70,7 +104,7 @@ export interface MakerConfig { } export const makerConfig: MakerConfig = { - symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT", + symbol: resolveSymbolFromEnv(), tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001), lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03)), bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0), diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index 198a0be..a415938 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -242,6 +242,8 @@ export async function placeStopLossOrder( timeInForce: "GTC", quantity: roundQtyDownToStep(quantity, qtyStep), }; + // 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段 + params.price = params.stopPrice; await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); lockOperating(locks, timers, pendings, type, log); try { diff --git a/src/exchanges/create-adapter.ts b/src/exchanges/create-adapter.ts index 93e4909..d191951 100644 --- a/src/exchanges/create-adapter.ts +++ b/src/exchanges/create-adapter.ts @@ -3,6 +3,7 @@ import { AsterExchangeAdapter, type AsterCredentials } from "./aster-adapter"; import { GrvtExchangeAdapter, type GrvtCredentials } from "./grvt/adapter"; import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapter"; import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter"; +import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter"; export interface ExchangeFactoryOptions { symbol: string; @@ -11,9 +12,10 @@ export interface ExchangeFactoryOptions { grvt?: GrvtCredentials; lighter?: LighterCredentials; backpack?: BackpackCredentials; + paradex?: ParadexCredentials; } -export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack"; +export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex"; export function resolveExchangeId(value?: string | null): SupportedExchangeId { const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster") @@ -23,6 +25,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId { if (fallback === "grvt") return "grvt"; if (fallback === "lighter") return "lighter"; if (fallback === "backpack") return "backpack"; + if (fallback === "paradex") return "paradex"; return "aster"; } @@ -30,6 +33,7 @@ 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"; return "AsterDex"; } @@ -44,5 +48,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange if (id === "backpack") { return new BackpackExchangeAdapter({ ...options.backpack, symbol: options.symbol }); } + if (id === "paradex") { + return new ParadexExchangeAdapter({ ...options.paradex, symbol: options.symbol }); + } return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol }); } diff --git a/src/exchanges/paradex/adapter.ts b/src/exchanges/paradex/adapter.ts new file mode 100644 index 0000000..9267164 --- /dev/null +++ b/src/exchanges/paradex/adapter.ts @@ -0,0 +1,210 @@ +import { setTimeout, clearTimeout } from "timers"; +import type { + AccountListener, + DepthListener, + ExchangeAdapter, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import type { AsterOrder, CreateOrderParams } from "../types"; +import { extractMessage } from "../../utils/errors"; +import { ParadexGateway, type ParadexGatewayOptions } from "./gateway"; + +export interface ParadexCredentials { + privateKey?: string; + walletAddress?: string; + sandbox?: boolean; + pollIntervals?: ParadexGatewayOptions["pollIntervals"]; + watchReconnectDelayMs?: number; + usePro?: boolean; + symbol?: string; +} + +export class ParadexExchangeAdapter implements ExchangeAdapter { + readonly id = "paradex"; + + private readonly gateway: ParadexGateway; + private readonly symbol: string; + private initPromise: Promise | null = null; + private readonly initContexts = new Set(); + private retryTimer: ReturnType | null = null; + private retryDelayMs = 3000; + private lastInitErrorAt = 0; + + constructor(credentials: ParadexCredentials = {}) { + const privateKey = credentials.privateKey ?? process.env.PARADEX_PRIVATE_KEY; + const walletAddress = credentials.walletAddress ?? process.env.PARADEX_WALLET_ADDRESS; + const sandbox = credentials.sandbox ?? (process.env.PARADEX_SANDBOX === "true"); + const symbol = credentials.symbol ?? process.env.PARADEX_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTC/USDC"; + const usePro = credentials.usePro ?? this.parseBooleanEnv(process.env.PARADEX_USE_PRO); + const watchReconnectDelayMs = + credentials.watchReconnectDelayMs ?? this.parseNumberEnv(process.env.PARADEX_RECONNECT_DELAY_MS); + + this.gateway = new ParadexGateway({ + symbol, + displaySymbol: symbol, + privateKey, + walletAddress, + sandbox, + pollIntervals: credentials.pollIntervals, + watchReconnectDelayMs, + usePro, + logger: (context, error) => this.logError(context, error), + }); + + this.symbol = symbol; + } + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(cb: AccountListener): void { + void this.ensureInitialized("watchAccount"); + this.gateway.onAccount(this.safeInvoke("watchAccount", cb)); + } + + watchOrders(cb: OrderListener): void { + void this.ensureInitialized("watchOrders"); + this.gateway.onOrders(this.safeInvoke("watchOrders", cb)); + } + + watchDepth(symbol: string, cb: DepthListener): void { + void this.ensureInitialized(`watchDepth:${symbol}`); + this.gateway.onDepth(this.safeInvoke("watchDepth", cb)); + } + + watchTicker(symbol: string, cb: TickerListener): void { + void this.ensureInitialized(`watchTicker:${symbol}`); + this.gateway.onTicker(this.safeInvoke("watchTicker", cb)); + } + + watchKlines(symbol: string, interval: string, cb: KlineListener): void { + void this.ensureInitialized(`watchKlines:${symbol}:${interval}`); + this.gateway.watchKlines(interval, this.safeInvoke("watchKlines", cb)); + } + + async createOrder(params: CreateOrderParams): Promise { + await this.ensureInitialized("createOrder"); + return this.gateway.createOrder(params); + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + await this.ensureInitialized("cancelOrder"); + await this.gateway.cancelOrder(params); + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + await this.ensureInitialized("cancelOrders"); + await this.gateway.cancelOrders(params); + } + + async cancelAllOrders(params: { symbol: string }): Promise { + await this.ensureInitialized("cancelAllOrders"); + await this.gateway.cancelAllOrders(params); + } + + private safeInvoke void>(context: string, cb: T): T { + const wrapped = ((...args: any[]) => { + try { + cb(...args); + } catch (error) { + console.error(`[ParadexExchangeAdapter] ${context} handler failed: ${extractMessage(error)}`); + } + }) as T; + return wrapped; + } + + private ensureInitialized(context?: string): Promise { + if (!this.initPromise) { + this.initContexts.clear(); + this.initPromise = this.gateway + .ensureInitialized(this.symbol) + .then((value) => { + this.clearRetry(); + return value; + }) + .catch((error) => { + this.handleInitError("initialize", error); + this.initPromise = null; + this.scheduleRetry(); + throw error; + }); + } + + if (context && !this.initContexts.has(context)) { + this.initContexts.add(context); + this.initPromise.catch((error) => { + this.handleInitError(context, error); + this.scheduleRetry(); + }); + } + + return this.initPromise; + } + + private scheduleRetry(): void { + if (this.retryTimer) return; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + if (this.initPromise) return; + this.retryDelayMs = Math.min(this.retryDelayMs * 2, 60_000); + void this.ensureInitialized("retry"); + }, this.retryDelayMs); + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + this.retryDelayMs = 3000; + } + + private handleInitError(context: string, error: unknown): void { + const now = Date.now(); + if (now - this.lastInitErrorAt < 5000) return; + this.lastInitErrorAt = now; + console.error(`[ParadexExchangeAdapter] ${context} failed`, error); + } + + private logError(context: string, error: unknown): void { + const detail = extractMessage(error); + if (context === "initialize" && typeof error === "string" && /initialized/i.test(error)) { + if (process.env.PARADEX_DEBUG === "1" || process.env.PARADEX_DEBUG === "true") { + console.info(`[ParadexExchangeAdapter] ${error}`); + } + return; + } + const message = `[ParadexExchangeAdapter] ${context} failed: ${detail}`; + const criticalContexts = [ + "initialize", + "accountPoll", + "watchBalanceLoop", + "orderPoll", + "orderPollOpen", + "orderPollClosed", + ]; + if ( + criticalContexts.some((prefix) => context.startsWith(prefix)) || + process.env.PARADEX_DEBUG === "1" || + process.env.PARADEX_DEBUG === "true" + ) { + console.error(message); + } + } + + private parseBooleanEnv(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (["false", "0", "no", "off", ""].includes(normalized)) return false; + return true; + } + + private parseNumberEnv(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } +} diff --git a/src/exchanges/paradex/gateway.ts b/src/exchanges/paradex/gateway.ts new file mode 100644 index 0000000..bb49a1f --- /dev/null +++ b/src/exchanges/paradex/gateway.ts @@ -0,0 +1,995 @@ +import ccxt, { + type Balances, + type Order as CcxtOrder, + type OrderBook as CcxtOrderBook, + type OHLCV as CcxtOhlcv, + type Ticker as CcxtTicker, +} from "ccxt"; +import { createRequire } from "module"; +import type { + AsterAccountAsset, + AsterAccountSnapshot, + AsterDepth, + AsterKline, + AsterOrder, + AsterTicker, + CreateOrderParams, + OrderType, +} from "../types"; +import type { + AccountListener, + DepthListener, + KlineListener, + OrderListener, + TickerListener, +} from "../adapter"; +import { extractMessage } from "../../utils/errors"; + +const require = createRequire(import.meta.url); + +function loadCcxtPro(): any | null { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require("ccxt.pro"); + return mod?.default ?? mod; + } catch (_error) { + return null; + } +} + +export interface ParadexGatewayOptions { + symbol: string; + displaySymbol: string; + privateKey?: string; + walletAddress?: string; + paradexAccount?: { + privateKey: string; + publicKey: string; + address: string; + }; + sandbox?: boolean; + logger?: (context: string, error: unknown) => void; + pollIntervals?: { + account?: number; + orders?: number; + depth?: number; + ticker?: number; + klines?: number; + }; + usePro?: boolean; + watchReconnectDelayMs?: number; +} + +interface ProLoopControl { + running: boolean; +} + +type ParadexPollingConfig = { + account: number; + orders: number; + depth: number; + ticker: number; + klines: number; +}; + +export class ParadexGateway { + private readonly exchange: any; + private readonly hasPro: boolean; + private readonly symbol: string; + private marketSymbol: string; + private readonly displaySymbol: string; + private readonly logger: (context: string, error: unknown) => void; + private readonly pollIntervals: ParadexPollingConfig; + private readonly reconnectDelayMs: number; + private initialized = false; + private initPromise: Promise | null = null; + private destroyed = false; + private onboardingChecked = false; + + private accountListeners = new Set(); + private orderListeners = new Set(); + private depthListeners = new Set(); + private tickerListeners = new Set(); + private klineListeners = new Map>(); + private readonly localOrders = new Map(); + private lastBalanceSnapshot: AsterAccountSnapshot | null = null; + + private accountPollTimer: NodeJS.Timeout | null = null; + private orderPollTimer: NodeJS.Timeout | null = null; + private depthPollTimer: NodeJS.Timeout | null = null; + private tickerPollTimer: NodeJS.Timeout | null = null; + private klinePollTimers = new Map(); + + private proLoops = new Map(); + private klineProLoops = new Map(); + + constructor(options: ParadexGatewayOptions) { + this.symbol = options.symbol; + this.marketSymbol = this.symbol; + this.displaySymbol = options.displaySymbol; + this.logger = options.logger ?? ((context, error) => console.error(`[ParadexGateway] ${context}:`, error)); + this.pollIntervals = { + account: options.pollIntervals?.account ?? 5000, + orders: options.pollIntervals?.orders ?? 2000, + depth: options.pollIntervals?.depth ?? 1000, + ticker: options.pollIntervals?.ticker ?? 2000, + klines: options.pollIntervals?.klines ?? 5000, + }; + this.reconnectDelayMs = options.watchReconnectDelayMs ?? 2000; + + const ccxtProModule = options.usePro === false ? null : loadCcxtPro(); + this.hasPro = Boolean(ccxtProModule && ccxtProModule.paradex); + + const ExchangeCtor = this.hasPro ? ccxtProModule.paradex : (ccxt as any).paradex; + + const exchangeOptions: Record = { + enableRateLimit: true, + timeout: 30000, + sandboxMode: options.sandbox ?? false, + }; + + if (options.privateKey) { + exchangeOptions.privateKey = options.privateKey; + } + exchangeOptions.walletAddress = options.walletAddress; + + this.exchange = new ExchangeCtor(exchangeOptions); + } + + async ensureInitialized(symbol?: string): Promise { + if (this.destroyed) { + throw new Error("Paradex gateway destroyed"); + } + if (this.initialized) { + if (symbol && symbol.toUpperCase() !== this.marketSymbol) { + const resolved = this.resolveMarketSymbol(symbol.toUpperCase()); + if (!resolved) { + throw new Error(`Symbol ${symbol} not found in Paradex markets`); + } + this.marketSymbol = resolved; + } + return; + } + if (this.initPromise) return this.initPromise; + this.initPromise = this.doInitialize(symbol) + .then((value) => { + this.initialized = true; + return value; + }) + .catch((error) => { + this.initPromise = null; + throw error; + }); + return this.initPromise; + } + + private async doInitialize(symbol?: string): Promise { + try { + await this.exchange.loadMarkets(); + const requested = symbol ?? this.symbol; + const resolved = this.resolveMarketSymbol(requested); + if (!resolved) { + throw new Error(`Symbol ${requested} not found in Paradex markets`); + } + this.marketSymbol = resolved; + await this.verifyAccountAccess(); + this.logger("initialize", `Paradex gateway initialized for ${this.marketSymbol}${this.hasPro ? " (pro)" : ""}`); + } catch (error) { + this.logger("initialize", error); + throw error; + } + } + + private resolveMarketSymbol(requested: string): string | null { + const symbol = (requested ?? "").trim(); + if (!symbol) { + return null; + } + + const markets = this.exchange.markets ?? {}; + const marketsById = this.exchange.markets_by_id ?? {}; + + if (markets[symbol]) { + return markets[symbol].symbol; + } + + if (typeof this.exchange.market === "function") { + try { + const market = this.exchange.market(symbol); + if (market?.symbol) { + return market.symbol; + } + } catch { + /* ignore */ + } + } + + const lower = symbol.toLowerCase(); + for (const candidate of this.exchange.symbols ?? []) { + if (candidate.toLowerCase() === lower && markets[candidate]) { + return markets[candidate].symbol; + } + } + + if (marketsById[symbol]) { + const entry = marketsById[symbol]; + if (Array.isArray(entry) && entry.length > 0) { + return (entry[0] as any).symbol ?? null; + } + return (entry as any).symbol ?? null; + } + + for (const [id, market] of Object.entries(marketsById)) { + if (id.toLowerCase() === lower) { + if (Array.isArray(market) && market.length > 0) { + return (market[0] as any).symbol ?? null; + } + return (market as any).symbol ?? null; + } + } + + return null; + } + + destroy(): void { + this.destroyed = true; + for (const [, control] of this.proLoops) { + control.running = false; + } + for (const [, control] of this.klineProLoops) { + control.running = false; + } + this.clearPolling(); + if (typeof this.exchange.close === "function") { + try { + void this.exchange.close(); + } catch (error) { + this.logger("destroy", error); + } + } + } + + private clearPolling(): void { + if (this.accountPollTimer) { + clearInterval(this.accountPollTimer); + this.accountPollTimer = null; + } + if (this.orderPollTimer) { + clearInterval(this.orderPollTimer); + this.orderPollTimer = null; + } + if (this.depthPollTimer) { + clearInterval(this.depthPollTimer); + this.depthPollTimer = null; + } + if (this.tickerPollTimer) { + clearInterval(this.tickerPollTimer); + this.tickerPollTimer = null; + } + for (const timer of this.klinePollTimers.values()) { + clearInterval(timer); + } + this.klinePollTimers.clear(); + } + + onAccount(callback: AccountListener): void { + this.accountListeners.add(callback); + if (this.lastBalanceSnapshot) { + try { + callback(this.lastBalanceSnapshot); + } catch (error) { + this.logger("accountReplay", error); + } + } + if (this.hasPro) { + this.startProLoop("account", () => this.watchAccountLoop()); + } else { + this.startAccountPolling(); + } + } + + onOrders(callback: OrderListener): void { + this.orderListeners.add(callback); + this.emitCurrentOrders(); + if (this.hasPro) { + this.startProLoop("orders", () => this.watchOrdersLoop()); + } else { + this.startOrderPolling(); + } + } + + onDepth(callback: DepthListener): void { + this.depthListeners.add(callback); + if (this.hasPro) { + this.startProLoop("depth", () => this.watchDepthLoop()); + } else { + this.startDepthPolling(); + } + } + + onTicker(callback: TickerListener): void { + this.tickerListeners.add(callback); + if (this.hasPro) { + this.startProLoop("ticker", () => this.watchTickerLoop()); + } else { + this.startTickerPolling(); + } + } + + watchKlines(interval: string, callback: KlineListener): void { + const normalizedInterval = this.normalizeInterval(interval); + if (!this.klineListeners.has(normalizedInterval)) { + this.klineListeners.set(normalizedInterval, new Set()); + } + this.klineListeners.get(normalizedInterval)?.add(callback); + if (this.hasPro) { + this.startKlineProLoop(normalizedInterval); + } else { + this.startKlinePolling(normalizedInterval); + } + } + + private startProLoop(key: string, pump: () => Promise): void { + if (this.proLoops.has(key)) return; + const control: ProLoopControl = { running: true }; + this.proLoops.set(key, control); + void this.runProLoop(key, control, pump); + } + + private async runProLoop(key: string, control: ProLoopControl, pump: () => Promise): Promise { + while (!this.destroyed && control.running) { + try { + await this.ensureInitialized(); + await pump(); + } catch (error) { + this.logger(`${key}Loop`, error); + await this.sleep(this.reconnectDelayMs); + } + } + this.proLoops.delete(key); + } + + private startKlineProLoop(interval: string): void { + if (this.klineProLoops.has(interval)) return; + const control: ProLoopControl = { running: true }; + this.klineProLoops.set(interval, control); + void this.runKlineProLoop(interval, control); + } + + private async runKlineProLoop(interval: string, control: ProLoopControl): Promise { + const key = `klines:${interval}`; + while (!this.destroyed && control.running) { + try { + await this.ensureInitialized(); + if (!this.klineListeners.get(interval)?.size) { + await this.sleep(500); + continue; + } + const raw = (await this.exchange.watchOHLCV(this.marketSymbol, interval)) as unknown; + const candles = Array.isArray(raw) && Array.isArray(raw[0]) ? (raw as CcxtOhlcv[]) : [raw as CcxtOhlcv]; + const klines = candles + .filter((candle) => Array.isArray(candle) && candle.length >= 6) + .map((candle) => this.mapOHLCVToKline(candle as CcxtOhlcv, interval)); + for (const listener of this.klineListeners.get(interval) ?? []) { + listener(klines); + } + } catch (error) { + this.logger(key, error); + await this.sleep(this.reconnectDelayMs); + } + } + this.klineProLoops.delete(interval); + } + + private async watchAccountLoop(): Promise { + if (!this.accountListeners.size) { + await this.sleep(500); + return; + } + await this.fetchAndEmitAccount(); + const hasWatchPositions = typeof (this.exchange as any).watchPositions === "function"; + if (hasWatchPositions) { + try { + const raw = await (this.exchange as any).watchPositions([this.marketSymbol]); + this.logger("watchPositionsRaw", JSON.stringify(raw)); + if (Array.isArray(raw)) { + const snapshot = this.mapBalanceToAccountSnapshotFromPositions(raw); + this.lastBalanceSnapshot = snapshot; + for (const listener of this.accountListeners) { + listener(snapshot); + } + } + } catch (error) { + this.logger("watchPositions", error); + } + } + await this.sleep(this.pollIntervals.account); + } + + private async watchOrdersLoop(): Promise { + if (!this.orderListeners.size) { + await this.sleep(500); + return; + } + const rawSymbol = this.exchange.marketId(this.displaySymbol) ?? this.marketSymbol; + const raw = (await this.exchange.watchOrders(rawSymbol)) as unknown; + const ordersArray = Array.isArray(raw) ? (raw as CcxtOrder[]) : [raw as CcxtOrder]; + this.updateOrdersFromRemote(ordersArray, []); + } + + private async watchDepthLoop(): Promise { + if (!this.depthListeners.size) { + await this.sleep(500); + return; + } + const depth = (await this.exchange.watchOrderBook(this.marketSymbol, 50)) as CcxtOrderBook; + const mapped = this.mapOrderBookToDepth(depth); + for (const listener of this.depthListeners) { + listener(mapped); + } + } + + private async watchTickerLoop(): Promise { + if (!this.tickerListeners.size) { + await this.sleep(500); + return; + } + const ticker = (await this.exchange.watchTicker(this.marketSymbol)) as CcxtTicker; + const mapped = this.mapTickerToAsterTicker(ticker); + for (const listener of this.tickerListeners) { + listener(mapped); + } + } + + private startAccountPolling(): void { + if (this.accountPollTimer) return; + const poll = async () => { + try { + await this.ensureInitialized(); + await this.fetchAndEmitAccount(); + } catch (error) { + this.logger("accountPoll", error); + } + }; + void poll(); + this.accountPollTimer = setInterval(() => void poll(), this.pollIntervals.account); + } + + private startOrderPolling(): void { + if (this.orderPollTimer) return; + const poll = async () => { + try { + await this.ensureInitialized(); + + let openOrders: CcxtOrder[] = []; + let closedOrders: CcxtOrder[] = []; + + try { + openOrders = (await this.exchange.fetchOpenOrders(this.marketSymbol)) as CcxtOrder[]; + } catch (error) { + this.logger("orderPollOpen", error); + } + + try { + closedOrders = (await this.exchange.fetchClosedOrders( + this.marketSymbol, + undefined, + 50 + )) as CcxtOrder[]; + } catch (error) { + this.logger("orderPollClosed", error); + } + + this.updateOrdersFromRemote(openOrders, closedOrders); + } catch (error) { + this.logger("orderPoll", error); + this.emitCurrentOrders(); + } + }; + void poll(); + this.orderPollTimer = setInterval(() => void poll(), this.pollIntervals.orders); + } + + private startDepthPolling(): void { + if (this.depthPollTimer) return; + const poll = async () => { + try { + await this.ensureInitialized(); + const orderbook = (await this.exchange.fetchOrderBook(this.marketSymbol, 50)) as CcxtOrderBook; + const depth = this.mapOrderBookToDepth(orderbook); + for (const listener of this.depthListeners) { + listener(depth); + } + } catch (error) { + this.logger("depthPoll", error); + } + }; + void poll(); + this.depthPollTimer = setInterval(() => void poll(), this.pollIntervals.depth); + } + + private startTickerPolling(): void { + if (this.tickerPollTimer) return; + const poll = async () => { + try { + await this.ensureInitialized(); + const ticker = (await this.exchange.fetchTicker(this.marketSymbol)) as CcxtTicker; + const mapped = this.mapTickerToAsterTicker(ticker); + for (const listener of this.tickerListeners) { + listener(mapped); + } + } catch (error) { + this.logger("tickerPoll", error); + } + }; + void poll(); + this.tickerPollTimer = setInterval(() => void poll(), this.pollIntervals.ticker); + } + + private startKlinePolling(interval: string): void { + if (this.klinePollTimers.has(interval)) return; + const poll = async () => { + try { + await this.ensureInitialized(); + const ohlcv = (await this.exchange.fetchOHLCV(this.marketSymbol, interval, undefined, 100)) as CcxtOhlcv[]; + const klines = ohlcv + .filter((candle) => Array.isArray(candle) && candle.length >= 6) + .map((candle) => this.mapOHLCVToKline(candle as CcxtOhlcv, interval)); + for (const listener of this.klineListeners.get(interval) ?? []) { + listener(klines); + } + } catch (error) { + this.logger(`klinePoll:${interval}`, error); + } + }; + void poll(); + this.klinePollTimers.set(interval, setInterval(() => void poll(), this.pollIntervals.klines)); + } + + async createOrder(params: CreateOrderParams): Promise { + await this.ensureInitialized(params.symbol); + const symbol = this.marketSymbol; + const type = this.mapOrderTypeToCcxt(params.type); + const side = params.side.toLowerCase(); + const amount = params.quantity; + const price = params.price; + + const extraParams: Record = {}; + if (params.stopPrice !== undefined) extraParams.stopPrice = params.stopPrice; + if (params.timeInForce) extraParams.timeInForce = params.timeInForce; + if (params.reduceOnly !== undefined) { + extraParams.reduceOnly = params.reduceOnly === "true"; + } + + try { + const order = (await this.exchange.createOrder( + symbol, + type, + side, + amount, + price, + extraParams + )) as CcxtOrder; + const mapped = this.mapOrderToAsterOrder(order); + this.upsertLocalOrder(mapped); + return mapped; + } catch (error) { + throw new Error(`Paradex createOrder failed: ${extractMessage(error)}`); + } + } + + async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { + await this.ensureInitialized(params.symbol); + try { + await this.exchange.cancelOrder(params.orderId, this.marketSymbol); + this.removeLocalOrder(String(params.orderId)); + } catch (error) { + throw new Error(`Paradex cancelOrder failed: ${extractMessage(error)}`); + } + } + + async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { + await this.ensureInitialized(params.symbol); + const errors: Array<{ id: number | string; error: unknown }> = []; + await Promise.all( + params.orderIdList.map(async (orderId) => { + try { + await this.exchange.cancelOrder(orderId, this.marketSymbol); + this.removeLocalOrder(String(orderId)); + } catch (error) { + errors.push({ id: orderId, error }); + } + }) + ); + if (errors.length) { + const messages = errors.map((entry) => `${entry.id}: ${extractMessage(entry.error)}`).join("; "); + throw new Error(`Paradex cancelOrders failed for ${messages}`); + } + } + + async cancelAllOrders(_params: { symbol: string }): Promise { + await this.ensureInitialized(); + try { + if (typeof this.exchange.cancelAllOrders === "function") { + await this.exchange.cancelAllOrders(this.marketSymbol); + } else { + const openOrders = (await this.exchange.fetchOpenOrders(this.marketSymbol)) as CcxtOrder[]; + await Promise.all(openOrders.map((order) => this.exchange.cancelOrder(order.id, this.marketSymbol))); + } + this.localOrders.clear(); + this.emitCurrentOrders(); + } catch (error) { + throw new Error(`Paradex cancelAllOrders failed: ${extractMessage(error)}`); + } + } + + private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot { + const now = Date.now(); + + const rawPositions = (() => { + const info = (balance as unknown as { info?: { positions?: unknown } })?.info; + const positionsValue = info?.positions; + if (!positionsValue) return [] as Array; + if (Array.isArray(positionsValue)) return positionsValue as Array; + return Object.values(positionsValue as Record); + })(); + + const positions = this.normalizePositions(rawPositions, now); + this.logger("positions", JSON.stringify({ raw: rawPositions, mapped: positions })); + + this.logger( + "balanceSnapshot", + JSON.stringify({ + total: balance.total, + free: balance.free, + used: balance.used, + }) + ); + + const free = (balance.free ?? {}) as Record; + const used = (balance.used ?? {}) as Record; + const total = (balance.total ?? {}) as Record; + + const assetKeys = new Set([ + ...Object.keys(free), + ...Object.keys(used), + ...Object.keys(total), + ]); + + const assets: AsterAccountAsset[] = Array.from(assetKeys).map((asset) => ({ + asset, + walletBalance: String(total[asset] ?? 0), + availableBalance: String(free[asset] ?? 0), + updateTime: now, + })); + + const totalWalletBalance = Array.from(assetKeys).reduce((acc, asset) => { + const value = total[asset]; + return acc + (typeof value === "number" ? value : Number(value ?? 0)); + }, 0); + + return { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: now, + totalWalletBalance: totalWalletBalance.toString(), + totalUnrealizedProfit: positions + .reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0) + .toString(), + positions, + assets, + }; + } + + private mapBalanceToAccountSnapshotFromPositions(rawPositions: any[]): AsterAccountSnapshot { + const now = Date.now(); + const positions = this.normalizePositions(rawPositions, now); + this.logger("positions", JSON.stringify({ raw: rawPositions, mapped: positions })); + const snapshot = this.lastBalanceSnapshot ?? { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: now, + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + positions: [], + assets: [], + }; + return { + ...snapshot, + updateTime: now, + totalUnrealizedProfit: positions + .reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0) + .toString(), + positions, + }; + } + + private normalizePositions(rawPositions: any[], now: number): AsterAccountSnapshot["positions"] { + return rawPositions + .filter((pos) => pos) + .map((pos: any) => { + const rawSymbol = + pos?.symbol ?? pos?.instrument ?? pos?.market ?? pos?.info?.market ?? this.marketSymbol; + const normalizedSymbol = + rawSymbol === this.marketSymbol || rawSymbol === this.displaySymbol + ? this.displaySymbol + : String(rawSymbol ?? this.displaySymbol); + + const quantityRaw = + pos?.positionAmt ?? pos?.contracts ?? pos?.size ?? pos?.amount ?? pos?.info?.size ?? 0; + let quantityNum = Number(quantityRaw); + if (!Number.isFinite(quantityNum)) { + quantityNum = Number(pos?.size ?? pos?.positionAmt ?? 0); + } + const rawSide = String( + pos?.side ?? pos?.info?.side ?? pos?.positionSide ?? pos?.position_side ?? "" + ).toLowerCase(); + const isShort = rawSide.includes("short") || rawSide.includes("sell"); + const positionAmt = isShort ? -Math.abs(quantityNum) : Math.abs(quantityNum); + + const entryPrice = + pos?.entryPrice ?? + pos?.averageEntryPrice ?? + pos?.info?.average_entry_price ?? + pos?.entry_price ?? + "0"; + const unrealized = + pos?.unrealizedPnl ?? + pos?.info?.unrealized_pnl ?? + pos?.unrealized_profit ?? + "0"; + + return { + symbol: normalizedSymbol, + positionAmt: positionAmt.toString(), + entryPrice: String(entryPrice ?? "0"), + unrealizedProfit: String(unrealized ?? "0"), + positionSide: "BOTH" as const, + updateTime: now, + }; + }); + } + + private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder { + const side = (order.side ?? "buy").toUpperCase() as "BUY" | "SELL"; + const mappedType = this.mapCcxtOrderTypeToAster(order.type); + return { + orderId: String(order.id ?? ""), + clientOrderId: (order.clientOrderId as any as string) || "", + symbol: this.displaySymbol, + side, + type: mappedType, + status: (order.status as any as string) || "", + price: order.price?.toString() || "0", + origQty: order.amount?.toString() || "0", + executedQty: order.filled?.toString() || "0", + stopPrice: order.stopPrice?.toString() || "0", + time: order.timestamp || Date.now(), + updateTime: order.lastUpdateTimestamp || Date.now(), + reduceOnly: Boolean((order.info?.reduceOnly as boolean | undefined) ?? false), + closePosition: Boolean((order.info?.closePosition as boolean | undefined) ?? false), + avgPrice: order.average?.toString(), + cumQuote: order.cost?.toString(), + }; + } + + private mapOrderBookToDepth(orderbook: CcxtOrderBook): AsterDepth { + return { + lastUpdateId: orderbook.nonce || Date.now(), + bids: (orderbook.bids || []) + .filter((t) => t && t.length >= 2) + .map(([price, amount]) => [String(price ?? 0), String(amount ?? 0)]), + asks: (orderbook.asks || []) + .filter((t) => t && t.length >= 2) + .map(([price, amount]) => [String(price ?? 0), String(amount ?? 0)]), + eventTime: orderbook.timestamp, + }; + } + + private mapTickerToAsterTicker(ticker: CcxtTicker): AsterTicker { + return { + symbol: ticker.symbol, + lastPrice: ticker.last?.toString() || "0", + openPrice: ticker.open?.toString() || "0", + highPrice: ticker.high?.toString() || "0", + lowPrice: ticker.low?.toString() || "0", + volume: ticker.baseVolume?.toString() || "0", + quoteVolume: ticker.quoteVolume?.toString() || "0", + eventTime: ticker.timestamp, + }; + } + + private mapOHLCVToKline(candle: CcxtOhlcv, interval: string): AsterKline { + const [timestampRaw, openRaw, highRaw, lowRaw, closeRaw, volumeRaw] = candle; + const timestamp = typeof timestampRaw === "number" && Number.isFinite(timestampRaw) + ? timestampRaw + : Date.now(); + const open = Number(openRaw ?? 0); + const high = Number(highRaw ?? 0); + const low = Number(lowRaw ?? 0); + const close = Number(closeRaw ?? 0); + const volume = Number(volumeRaw ?? 0); + return { + openTime: timestamp, + closeTime: timestamp + this.getIntervalMs(interval), + open: open.toString(), + high: high.toString(), + low: low.toString(), + close: close.toString(), + volume: volume.toString(), + numberOfTrades: 0, + }; + } + + private mapOrderTypeToCcxt(type: string): string { + const typeMap: Record = { + LIMIT: "limit", + MARKET: "market", + STOP_MARKET: "stop", + TRAILING_STOP_MARKET: "trailing-stop", + }; + return typeMap[type] || "limit"; + } + + private mapCcxtOrderTypeToAster(type: string | undefined): OrderType { + const typeMap: Record = { + limit: "LIMIT", + market: "MARKET", + stop: "STOP_MARKET", + "trailing-stop": "TRAILING_STOP_MARKET", + }; + return type ? typeMap[type] ?? "LIMIT" : "LIMIT"; + } + + private normalizeInterval(interval: string): string { + const map: Record = { + "1m": "1m", + "3m": "3m", + "5m": "5m", + "15m": "15m", + "30m": "30m", + "1h": "1h", + "4h": "4h", + "1d": "1d", + }; + return map[interval] ?? "1m"; + } + + private getIntervalMs(interval: string): number { + const base: Record = { + "1m": 60 * 1000, + "3m": 3 * 60 * 1000, + "5m": 5 * 60 * 1000, + "15m": 15 * 60 * 1000, + "30m": 30 * 60 * 1000, + "1h": 60 * 60 * 1000, + "4h": 4 * 60 * 60 * 1000, + "1d": 24 * 60 * 60 * 1000, + }; + return base[interval] ?? 60 * 1000; + } + + private upsertLocalOrder(order: AsterOrder): void { + const key = String(order.orderId); + if (this.isOrderClosed(order)) { + this.localOrders.delete(key); + this.emitCurrentOrders(); + return; + } + this.localOrders.set(key, order); + this.emitCurrentOrders(); + } + + private removeLocalOrder(orderId: string): void { + if (this.localOrders.delete(orderId)) { + this.emitCurrentOrders(); + } + } + + private updateOrdersFromRemote(open: CcxtOrder[], closed: CcxtOrder[]): void { + const nextOpen = new Map(); + + for (const order of open) { + const mapped = this.mapOrderToAsterOrder(order); + if (!this.isOrderClosed(mapped)) { + nextOpen.set(String(mapped.orderId), mapped); + } + } + + this.localOrders.clear(); + for (const [id, order] of nextOpen.entries()) { + this.localOrders.set(id, order); + } + + this.emitCurrentOrders(); + } + + private emitCurrentOrders(): void { + if (!this.orderListeners.size) return; + const open = Array.from(this.localOrders.values()).filter((order) => !this.isOrderClosed(order)); + for (const listener of this.orderListeners) { + try { + listener(open); + } catch (error) { + this.logger("emitOrders", error); + } + } + } + + private isOrderClosed(order: AsterOrder): boolean { + const status = (order.status ?? "").toUpperCase(); + if ( + status.includes("CLOSE") || + status.includes("FILLED") || + status.includes("CANCEL") || + status.includes("REJECT") + ) { + return true; + } + const orig = Number(order.origQty ?? 0); + const filled = Number(order.executedQty ?? 0); + if (Number.isFinite(orig) && Number.isFinite(filled) && Math.abs(orig - filled) < 1e-12) { + return true; + } + return false; + } + + private sleep(duration: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, duration); + }); + } + + private async fetchAndEmitAccount(): Promise { + const balance = (await this.exchange.fetchBalance()) as Balances; + await this.attachPositions(balance); + const snapshot = this.mapBalanceToAccountSnapshot(balance); + this.lastBalanceSnapshot = snapshot; + for (const listener of this.accountListeners) { + listener(snapshot); + } + } + + private async attachPositions(balance: Balances): Promise { + const fetchPositions = (this.exchange as any).fetchPositions; + if (typeof fetchPositions !== "function") return; + try { + const positions = await fetchPositions.call(this.exchange); + this.logger("fetchPositionsResponse", JSON.stringify(positions)); + if (Array.isArray(positions)) { + ((balance as unknown as { info?: Record }).info ??= {}).positions = positions; + } + } catch (error) { + this.logger("fetchPositions", error); + } + } + + private async verifyAccountAccess(): Promise { + if (this.onboardingChecked) return; + try { + await this.exchange.fetchBalance(); + this.onboardingChecked = true; + } catch (error) { + if (this.isNotOnboardedError(error)) { + throw new Error( + "Paradex account is not onboarded. Please complete the /onboarding flow on Paradex before running the bot." + ); + } + throw error; + } + } + + private isNotOnboardedError(error: unknown): boolean { + const message = extractMessage(error).toUpperCase(); + if (message.includes("NOT_ONBOARDED")) return true; + const body = (error as any)?.body ?? (error as any)?.response; + if (typeof body === "string" && body.toUpperCase().includes("NOT_ONBOARDED")) return true; + if (body && typeof body === "object") { + const serialized = JSON.stringify(body).toUpperCase(); + if (serialized.includes("NOT_ONBOARDED")) return true; + } + return false; + } +} diff --git a/src/exchanges/resolve-from-env.ts b/src/exchanges/resolve-from-env.ts new file mode 100644 index 0000000..1768d57 --- /dev/null +++ b/src/exchanges/resolve-from-env.ts @@ -0,0 +1,137 @@ +import type { ExchangeAdapter } from "./adapter"; +import { createExchangeAdapter, resolveExchangeId, type SupportedExchangeId } from "./create-adapter"; +import type { AsterCredentials } from "./aster-adapter"; +import type { LighterCredentials } from "./lighter/adapter"; +import type { BackpackCredentials } from "./backpack/adapter"; +import type { ParadexCredentials } from "./paradex/adapter"; + +interface BuildAdapterOptions { + symbol: string; + exchangeId?: string | SupportedExchangeId; +} + +export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapter { + const id = resolveExchangeId(options.exchangeId); + const symbol = options.symbol; + + if (id === "aster") { + const credentials = resolveAsterCredentials(); + return createExchangeAdapter({ exchange: id, symbol, aster: 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 }); + } + + return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } }); +} + +function resolveAsterCredentials(): AsterCredentials { + const apiKey = process.env.ASTER_API_KEY; + const apiSecret = process.env.ASTER_API_SECRET; + if (!apiKey || !apiSecret) { + throw new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"); + } + return { apiKey, apiSecret }; +} + +function resolveLighterCredentials(symbol: string): LighterCredentials { + const accountIndexRaw = process.env.LIGHTER_ACCOUNT_INDEX; + const apiPrivateKey = process.env.LIGHTER_API_PRIVATE_KEY; + if (!accountIndexRaw || !apiPrivateKey) { + throw new Error("缺少 LIGHTER_ACCOUNT_INDEX 或 LIGHTER_API_PRIVATE_KEY 环境变量"); + } + const accountIndex = Number(accountIndexRaw); + if (!Number.isInteger(accountIndex)) { + throw new Error("LIGHTER_ACCOUNT_INDEX 必须是整数"); + } + const credentials: LighterCredentials = { + displaySymbol: symbol, + accountIndex, + apiPrivateKey, + apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0, + environment: process.env.LIGHTER_ENV, + baseUrl: process.env.LIGHTER_BASE_URL, + l1Address: process.env.LIGHTER_L1_ADDRESS, + marketSymbol: process.env.LIGHTER_SYMBOL, + marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined, + priceDecimals: process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined, + sizeDecimals: process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined, + }; + return credentials; +} + +function resolveBackpackCredentials(symbol: string): BackpackCredentials { + const apiKey = process.env.BACKPACK_API_KEY; + const apiSecret = process.env.BACKPACK_API_SECRET; + if (!apiKey || !apiSecret) { + throw new Error("缺少 BACKPACK_API_KEY 或 BACKPACK_API_SECRET 环境变量"); + } + const credentials: BackpackCredentials = { + apiKey, + apiSecret, + password: process.env.BACKPACK_PASSWORD, + subaccount: process.env.BACKPACK_SUBACCOUNT, + symbol: process.env.BACKPACK_SYMBOL ?? symbol, + sandbox: parseOptionalBoolean(process.env.BACKPACK_SANDBOX), + }; + return credentials; +} + +function resolveParadexCredentials(): ParadexCredentials { + const privateKey = process.env.PARADEX_PRIVATE_KEY; + const walletAddress = process.env.PARADEX_WALLET_ADDRESS; + + if (!privateKey || !walletAddress) { + throw new Error("Paradex 需要配置 PARADEX_PRIVATE_KEY 与 PARADEX_WALLET_ADDRESS"); + } + if (!isHex32(privateKey)) { + throw new Error("PARADEX_PRIVATE_KEY 必须是 0x 开头的 32 字节十六进制字符串"); + } + if (!isHexAddress(walletAddress)) { + throw new Error("PARADEX_WALLET_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址"); + } + + const credentials: ParadexCredentials = { + privateKey, + walletAddress, + sandbox: parseOptionalBoolean(process.env.PARADEX_SANDBOX), + usePro: parseOptionalBoolean(process.env.PARADEX_USE_PRO), + watchReconnectDelayMs: parseOptionalNumber(process.env.PARADEX_RECONNECT_DELAY_MS), + }; + + return credentials; +} + +function isHex32(value: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(value.trim()); +} + +function isHexAddress(value: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(value.trim()); +} + +function parseOptionalBoolean(value: string | undefined): boolean | undefined { + if (value == null) return undefined; + const normalized = value.trim().toLowerCase(); + if (!normalized) return undefined; + if (["false", "0", "no", "off"].includes(normalized)) return false; + return true; +} + +function parseOptionalNumber(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/src/strategy/maker-engine.ts b/src/strategy/maker-engine.ts index d1ac368..9335c3b 100644 --- a/src/strategy/maker-engine.ts +++ b/src/strategy/maker-engine.ts @@ -9,7 +9,7 @@ import type { } from "../exchanges/types"; import { formatPriceToString } from "../utils/math"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; -import { isUnknownOrderError, isRateLimitError } from "../utils/errors"; +import { extractMessage, isInsufficientBalanceError, isUnknownOrderError, isRateLimitError } from "../utils/errors"; import { getPosition } from "../utils/strategy"; import type { PositionSnapshot } from "../utils/strategy"; import { computePositionPnl } from "../utils/pnl"; @@ -49,12 +49,19 @@ export interface MakerEngineSnapshot { desiredOrders: DesiredOrder[]; tradeLog: TradeLogEntry[]; lastUpdated: number | null; + feedStatus: { + account: boolean; + orders: boolean; + depth: boolean; + ticker: boolean; + }; } type MakerEvent = "update"; type MakerListener = (snapshot: MakerEngineSnapshot) => void; const EPS = 1e-5; +const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; export class MakerEngine { private accountSnapshot: AsterAccountSnapshot | null = null; @@ -78,6 +85,28 @@ export class MakerEngine { private initialOrderSnapshotReady = false; private initialOrderResetDone = false; private entryPricePendingLogged = false; + private readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + private feedArrived = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + private feedStatus = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + private insufficientBalanceCooldownUntil = 0; + private insufficientBalanceNotified = false; + private lastInsufficientMessage: string | null = null; + private lastDesiredSummary: string | null = null; private readonly rateLimit: RateLimitController; constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) { @@ -127,6 +156,11 @@ export class MakerEngine { } const position = getPosition(snapshot, this.config.symbol); this.sessionVolume.update(position, this.getReferencePrice()); + if (!this.feedArrived.account) { + this.tradeLog.push("info", "账户快照已同步"); + this.feedArrived.account = true; + } + this.feedStatus.account = true; this.emitUpdate(); }, log, @@ -150,6 +184,11 @@ export class MakerEngine { } } this.initialOrderSnapshotReady = true; + if (!this.feedArrived.orders) { + this.tradeLog.push("info", "订单快照已返回"); + this.feedArrived.orders = true; + } + this.feedStatus.orders = true; this.emitUpdate(); }, log, @@ -163,6 +202,11 @@ export class MakerEngine { this.exchange.watchDepth.bind(this.exchange, this.config.symbol), (depth) => { this.depthSnapshot = depth; + if (!this.feedArrived.depth) { + this.tradeLog.push("info", "获得最新深度行情"); + this.feedArrived.depth = true; + } + this.feedStatus.depth = true; this.emitUpdate(); }, log, @@ -176,6 +220,11 @@ export class MakerEngine { this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; + if (!this.feedArrived.ticker) { + this.tradeLog.push("info", "Ticker 已就绪"); + this.feedArrived.ticker = true; + } + this.feedStatus.ticker = true; this.emitUpdate(); }, log, @@ -185,18 +234,7 @@ export class MakerEngine { } ); - // Maker strategy does not consume klines, but subscribe to keep parity with other modules - safeSubscribe( - this.exchange.watchKlines.bind(this.exchange, this.config.symbol, "1m"), - (_klines) => { - /* no-op */ - }, - log, - { - subscribeFail: (error) => `订阅K线失败: ${String(error)}`, - processFail: (error) => `K线推送处理异常: ${String(error)}`, - } - ); + // Maker strategy does not require realtime klines. } private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void { @@ -212,7 +250,12 @@ export class MakerEngine { } private isReady(): boolean { - return Boolean(this.accountSnapshot && this.depthSnapshot); + return Boolean( + this.feedStatus.account && + this.feedStatus.depth && + this.feedStatus.ticker && + this.feedStatus.orders + ); } private async tick(): Promise { @@ -229,9 +272,11 @@ export class MakerEngine { return; } if (!this.isReady()) { + this.logReadinessBlockers(); this.emitUpdate(); return; } + this.resetReadinessFlags(); if (!(await this.ensureStartupOrderReset())) { this.emitUpdate(); return; @@ -253,7 +298,8 @@ export class MakerEngine { const position = getPosition(this.accountSnapshot, this.config.symbol); const absPosition = Math.abs(position.positionAmt); const desired: DesiredOrder[] = []; - const canEnter = !this.rateLimit.shouldBlockEntries(); + const insufficientActive = this.applyInsufficientBalanceState(Date.now()); + const canEnter = !this.rateLimit.shouldBlockEntries() && !insufficientActive; if (absPosition < EPS) { this.entryPricePendingLogged = false; @@ -268,6 +314,7 @@ export class MakerEngine { } this.desiredOrders = desired; + this.logDesiredOrders(desired); this.sessionVolume.update(position, this.getReferencePrice()); await this.syncOrders(desired); await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice)); @@ -331,7 +378,11 @@ export class MakerEngine { private async syncOrders(targets: DesiredOrder[]): Promise { const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId))); - const { toCancel, toPlace } = makeOrderPlan(availableOrders, targets); + const openOrders = availableOrders.filter((order) => { + const status = (order.status ?? "").toUpperCase(); + return !status.includes("CLOSED") && !status.includes("FILLED") && !status.includes("CANCELED"); + }); + const { toCancel, toPlace } = makeOrderPlan(openOrders, targets); for (const order of toCancel) { if (this.pendingCancelOrders.has(String(order.orderId))) continue; @@ -385,7 +436,14 @@ export class MakerEngine { } ); } catch (error) { - this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); + if (isInsufficientBalanceError(error)) { + this.registerInsufficientBalance(error); + break; + } + this.tradeLog.push( + "error", + `挂单失败(${target.side} ${target.price}): ${extractMessage(error)}` + ); } } } @@ -500,10 +558,81 @@ export class MakerEngine { desiredOrders: this.desiredOrders, tradeLog: this.tradeLog.all(), lastUpdated: Date.now(), + feedStatus: { ...this.feedStatus }, }; } private getReferencePrice(): number | null { return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); } + + private logReadinessBlockers(): void { + if (!this.feedStatus.account && !this.readinessLogged.account) { + this.tradeLog.push("info", "等待账户快照同步,尚未开始做市"); + this.readinessLogged.account = true; + } + if (!this.feedStatus.depth && !this.readinessLogged.depth) { + this.tradeLog.push("info", "等待深度行情推送,尚未开始做市"); + this.readinessLogged.depth = true; + } + if (!this.feedStatus.ticker && !this.readinessLogged.ticker) { + this.tradeLog.push("info", "等待Ticker推送,尚未开始做市"); + this.readinessLogged.ticker = true; + } + if (!this.feedStatus.orders && !this.readinessLogged.orders) { + this.tradeLog.push("info", "等待订单快照返回,尚未执行初始化撤单"); + this.readinessLogged.orders = true; + } + } + + private resetReadinessFlags(): void { + this.readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + } + + private logDesiredOrders(desired: DesiredOrder[]): void { + if (!desired.length) { + if (this.lastDesiredSummary !== "none") { + this.tradeLog.push("info", "当前无目标挂单,等待下一次刷新"); + this.lastDesiredSummary = "none"; + } + return; + } + const summary = desired + .map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`) + .join(" | "); + if (summary !== this.lastDesiredSummary) { + this.tradeLog.push("info", `目标挂单: ${summary}`); + this.lastDesiredSummary = summary; + } + } + + private registerInsufficientBalance(error: unknown): void { + const now = Date.now(); + const detail = extractMessage(error); + const alreadyActive = now < this.insufficientBalanceCooldownUntil; + if (alreadyActive && detail === this.lastInsufficientMessage) { + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + return; + } + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + this.lastInsufficientMessage = detail; + const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000); + this.tradeLog.push("warn", `余额不足,暂停新挂单 ${seconds}s: ${detail}`); + this.insufficientBalanceNotified = true; + } + + private applyInsufficientBalanceState(now: number): boolean { + const active = now < this.insufficientBalanceCooldownUntil; + if (!active && this.insufficientBalanceNotified) { + this.tradeLog.push("info", "余额检测恢复,重新尝试挂单"); + this.insufficientBalanceNotified = false; + this.lastInsufficientMessage = null; + } + return active; + } } diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index 7f89e51..efeefd9 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -449,6 +449,10 @@ export class OffsetMakerEngine { private async syncOrders(targets: DesiredOrder[]): Promise { const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId))); + const openOrders = availableOrders.filter((order) => { + const status = (order.status ?? "").toUpperCase(); + return !status.includes("CLOSED") && !status.includes("FILLED") && !status.includes("CANCELED"); + }); // Coalesce reprices for entry orders: if within tick threshold or within dwell window, keep existing order const adjustedTargets: DesiredOrder[] = targets.map((t) => ({ ...t })); @@ -474,7 +478,7 @@ export class OffsetMakerEngine { } } - const { toCancel, toPlace } = makeOrderPlan(availableOrders, adjustedTargets); + const { toCancel, toPlace } = makeOrderPlan(openOrders, adjustedTargets); for (const order of toCancel) { if (this.pendingCancelOrders.has(String(order.orderId))) continue; diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index df7042e..6c7254c 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -242,6 +242,8 @@ export class TrendEngine { this.exchange.watchKlines.bind(this.exchange, this.config.symbol, this.config.klineInterval), (klines) => { this.klineSnapshot = Array.isArray(klines) ? klines : []; + const latestSma = getSMA(this.klineSnapshot, 30); + this.lastSma30 = latestSma; this.logKlineSnapshot(); this.emitUpdate(); }, diff --git a/src/ui/MakerApp.tsx b/src/ui/MakerApp.tsx index 027209c..8ee81f1 100644 --- a/src/ui/MakerApp.tsx +++ b/src/ui/MakerApp.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { makerConfig } from "../config"; -import { - createExchangeAdapter, - getExchangeDisplayName, - resolveExchangeId, -} from "../exchanges/create-adapter"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; @@ -35,50 +32,7 @@ export function MakerApp({ onExit }: MakerAppProps) { useEffect(() => { try { - let adapter; - if (exchangeId === "aster") { - const apiKey = process.env.ASTER_API_KEY; - const apiSecret = process.env.ASTER_API_SECRET; - if (!apiKey || !apiSecret) { - setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量")); - return; - } - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: makerConfig.symbol, - aster: { apiKey, apiSecret }, - }); - } else if (exchangeId === "lighter") { - const accountIndexRaw = process.env.LIGHTER_ACCOUNT_INDEX; - const apiPrivateKey = process.env.LIGHTER_API_PRIVATE_KEY; - if (!accountIndexRaw || !apiPrivateKey) { - setError(new Error("缺少 LIGHTER_ACCOUNT_INDEX 或 LIGHTER_API_PRIVATE_KEY 环境变量")); - return; - } - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: makerConfig.symbol, - lighter: { - displaySymbol: makerConfig.symbol, - accountIndex: parseInt(accountIndexRaw, 10), - apiPrivateKey, - apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0, - environment: process.env.LIGHTER_ENV, - baseUrl: process.env.LIGHTER_BASE_URL, - marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined, - marketSymbol: process.env.LIGHTER_SYMBOL, - priceDecimals: process.env.LIGHTER_PRICE_DECIMALS ? Number(process.env.LIGHTER_PRICE_DECIMALS) : undefined, - sizeDecimals: process.env.LIGHTER_SIZE_DECIMALS ? Number(process.env.LIGHTER_SIZE_DECIMALS) : undefined, - l1Address: process.env.LIGHTER_L1_ADDRESS, - }, - }); - } else { - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: makerConfig.symbol, - grvt: { symbol: makerConfig.symbol }, - }); - } + const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol }); const engine = new MakerEngine(makerConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); @@ -154,6 +108,13 @@ export function MakerApp({ onExit }: MakerAppProps) { ]; const lastLogs = snapshot.tradeLog.slice(-5); + const feedStatus = snapshot.feedStatus; + const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [ + { key: "account", label: "账户" }, + { key: "orders", label: "订单" }, + { key: "depth", label: "深度" }, + { key: "ticker", label: "Ticker" }, + ]; return ( @@ -163,6 +124,15 @@ export function MakerApp({ onExit }: MakerAppProps) { 交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay} 状态: {snapshot.ready ? "实时运行" : "等待市场数据"} | 按 Esc 返回策略选择 + + 数据状态: + {feedEntries.map((entry, index) => ( + + {index === 0 ? " " : " "} + {entry.label} + + ))} + diff --git a/src/ui/OffsetMakerApp.tsx b/src/ui/OffsetMakerApp.tsx index d13e199..db73616 100644 --- a/src/ui/OffsetMakerApp.tsx +++ b/src/ui/OffsetMakerApp.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { makerConfig } from "../config"; -import { - createExchangeAdapter, - getExchangeDisplayName, - resolveExchangeId, -} from "../exchanges/create-adapter"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; @@ -35,26 +32,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) { useEffect(() => { try { - let adapter; - if (exchangeId === "aster") { - const apiKey = process.env.ASTER_API_KEY; - const apiSecret = process.env.ASTER_API_SECRET; - if (!apiKey || !apiSecret) { - setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量")); - return; - } - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: makerConfig.symbol, - aster: { apiKey, apiSecret }, - }); - } else { - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: makerConfig.symbol, - grvt: { symbol: makerConfig.symbol }, - }); - } + const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol }); const engine = new OffsetMakerEngine(makerConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); diff --git a/src/ui/TrendApp.tsx b/src/ui/TrendApp.tsx index 5696ef6..af52d35 100644 --- a/src/ui/TrendApp.tsx +++ b/src/ui/TrendApp.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { tradingConfig } from "../config"; -import { - createExchangeAdapter, - getExchangeDisplayName, - resolveExchangeId, -} from "../exchanges/create-adapter"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; import { formatNumber } from "../utils/format"; import { DataTable, type TableColumn } from "./components/DataTable"; @@ -37,26 +34,7 @@ export function TrendApp({ onExit }: TrendAppProps) { useEffect(() => { try { - let adapter; - if (exchangeId === "aster") { - const apiKey = process.env.ASTER_API_KEY; - const apiSecret = process.env.ASTER_API_SECRET; - if (!apiKey || !apiSecret) { - setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量")); - return; - } - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: tradingConfig.symbol, - aster: { apiKey, apiSecret }, - }); - } else { - adapter = createExchangeAdapter({ - exchange: exchangeId, - symbol: tradingConfig.symbol, - grvt: { symbol: tradingConfig.symbol }, - }); - } + const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol }); const engine = new TrendEngine(tradingConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 2825321..cbc0cf8 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,7 +1,14 @@ export function isUnknownOrderError(error: unknown): boolean { const message = extractMessage(error); if (!message) return false; - return message.includes("Unknown order") || message.includes("code\":-2011"); + const upper = message.toUpperCase(); + return ( + upper.includes("UNKNOWN ORDER") || + upper.includes("CODE\":-2011") || + upper.includes("ORDER_ID_NOT_FOUND") || + upper.includes("ORDER_IS_CLOSED") || + upper.includes("COULD NOT FIND ORDER") + ); } export function extractMessage(error: unknown): string { @@ -36,3 +43,13 @@ export function isRateLimitError(error: unknown): boolean { message.includes("request rate") ); } + +export function isInsufficientBalanceError(error: unknown): boolean { + const message = extractMessage(error).toUpperCase(); + return ( + message.includes("INSUFFICIENT") || + message.includes("NOT_ENOUGH_BALANCE") || + message.includes("INSUFFICIENT_BALANCE") || + message.includes("NOT ENOUGH") + ); +} diff --git a/src/utils/strategy.ts b/src/utils/strategy.ts index 77ea045..7bf2d34 100644 --- a/src/utils/strategy.ts +++ b/src/utils/strategy.ts @@ -32,10 +32,18 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin } export function getSMA(values: AsterKline[], length: number): number | null { - if (!values || values.length < length) return null; - const closes = values.slice(-length).map((k) => Number(k.close)); + if (!Array.isArray(values) || values.length < length) return null; + const window = values.slice(-length); + const closes = window.map((kline) => Number(kline.close)); + if (closes.some((price) => !Number.isFinite(price))) { + return null; + } const sum = closes.reduce((acc, current) => acc + current, 0); - return sum / closes.length; + if (!Number.isFinite(sum)) { + return null; + } + const average = sum / closes.length; + return Number.isFinite(average) ? average : null; } export function calcStopLossPrice(entryPrice: number, qty: number, side: "long" | "short", loss: number): number { diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..90dbbe0 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveSymbolFromEnv } from "../src/config"; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("resolveSymbolFromEnv", () => { + it("prefers exchange-specific symbol when available", () => { + process.env.EXCHANGE = "backpack"; + process.env.BACKPACK_SYMBOL = "ETHUSDC"; + process.env.TRADE_SYMBOL = "BTCUSDT"; + + expect(resolveSymbolFromEnv()).toBe("ETHUSDC"); + }); + + it("falls back to TRADE_SYMBOL when exchange symbol is missing", () => { + process.env.EXCHANGE = "paradex"; + process.env.TRADE_SYMBOL = "ETH/USDC"; + delete process.env.PARADEX_SYMBOL; + + expect(resolveSymbolFromEnv()).toBe("ETH/USDC"); + }); + + it("uses exchange-specific fallback when no env is defined", () => { + process.env.EXCHANGE = "paradex"; + delete process.env.PARADEX_SYMBOL; + delete process.env.TRADE_SYMBOL; + + expect(resolveSymbolFromEnv()).toBe("BTC/USDC"); + }); + + it("supports resolving symbol for an explicit exchange id", () => { + delete process.env.EXCHANGE; + process.env.GRVT_SYMBOL = "ETHUSDT"; + + expect(resolveSymbolFromEnv("grvt")).toBe("ETHUSDT"); + }); +}); + diff --git a/tests/exchange-factory.test.ts b/tests/exchange-factory.test.ts index d079def..ab6df38 100644 --- a/tests/exchange-factory.test.ts +++ b/tests/exchange-factory.test.ts @@ -3,6 +3,7 @@ import { createExchangeAdapter, resolveExchangeId } from "../src/exchanges/creat import { AsterExchangeAdapter } from "../src/exchanges/aster-adapter"; import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter"; import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter"; +import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter"; const ORIGINAL_ENV = { ...process.env }; @@ -28,6 +29,7 @@ describe("exchange factory", () => { expect(resolveExchangeId("Grvt")).toBe("grvt"); expect(resolveExchangeId("ASTER")).toBe("aster"); expect(resolveExchangeId("BACKPACK")).toBe("backpack"); + expect(resolveExchangeId("PaRaDeX")).toBe("paradex"); }); it("creates grvt adapter when EXCHANGE=grvt", () => { @@ -54,4 +56,15 @@ describe("exchange factory", () => { expect(adapter).toBeInstanceOf(BackpackExchangeAdapter); expect(adapter.id).toBe("backpack"); }); + + it("creates paradex adapter when EXCHANGE=paradex", () => { + process.env.EXCHANGE = "paradex"; + process.env.PARADEX_PRIVATE_KEY = "0x" + "1".repeat(64); + process.env.PARADEX_WALLET_ADDRESS = "0x" + "2".repeat(40); + process.env.PARADEX_SYMBOL = "BTC/USDC"; + + const adapter = createExchangeAdapter({ symbol: "BTC/USDC" }); + expect(adapter).toBeInstanceOf(ParadexExchangeAdapter); + expect(adapter.id).toBe("paradex"); + }); }); From e28240e7991cd3c9f8cc0df05fe7fd2d9898f338 Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Oct 2025 18:44:16 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=8D=8F=E8=B0=83=E5=99=A8=E5=92=8C=20GRVT=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=AD=A2=E6=8D=9F=E5=8D=95=E7=9A=84=20reduceOnly=20?= =?UTF-8?q?=E5=92=8C=20closePosition=20=E5=8F=82=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=BB=98=E8=AE=A4=E4=BB=B7=E6=A0=BC=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=99=A8=E4=B8=BA=20LAST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/order-coordinator.ts | 1 + src/exchanges/grvt/gateway.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index a415938..e56e603 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -238,6 +238,7 @@ export async function placeStopLossOrder( side, type, stopPrice: roundDownToTick(stopPrice, priceTick), + reduceOnly: "true", closePosition: "true", timeInForce: "GTC", quantity: roundQtyDownToStep(quantity, qtyStep), diff --git a/src/exchanges/grvt/gateway.ts b/src/exchanges/grvt/gateway.ts index 922d73c..cd4fff1 100644 --- a/src/exchanges/grvt/gateway.ts +++ b/src/exchanges/grvt/gateway.ts @@ -73,7 +73,7 @@ const ENVIRONMENT_ALIASES: Record = { production: "prod", }; -const DEFAULT_MARK_PRICE_TRIGGER = "MARK"; +const DEFAULT_MARK_PRICE_TRIGGER = "LAST"; const DEFAULT_TIME_IN_FORCE: GrvtTimeInForce = "GOOD_TILL_TIME"; const TRAILING_NOT_SUPPORTED_ERROR = "GRVT exchange adapter does not yet support trailing stop orders"; @@ -1097,12 +1097,16 @@ function mapOrder(order: IOrder, symbol: string): AsterOrder { const avgFillPrice = Array.isArray(state?.avg_fill_price) ? state?.avg_fill_price?.[0] ?? undefined : state?.avg_fill_price ?? undefined; + const hasTrigger = Boolean(trigger?.tpsl?.trigger_price); + const derivedType = hasTrigger + ? (order.is_market ? "STOP_MARKET" : "LIMIT") + : (order.is_market ? "MARKET" : "LIMIT"); return { orderId: order.order_id ?? metadata?.client_order_id ?? cryptoRandomId(), clientOrderId: metadata?.client_order_id ?? "", symbol, side: leg?.is_buying_asset ? "BUY" : "SELL", - type: order.is_market ? "MARKET" : "LIMIT", + type: derivedType, status: state?.status ?? "NEW", price: leg?.limit_price ?? "0", origQty: leg?.size ?? "0", From d2867ba98c6219b6cd918000115833b7a4630d35 Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Oct 2025 19:06:49 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E5=8D=8F=E8=B0=83=E5=99=A8=E5=92=8C=20GRVT=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=B1=BB=E5=9E=8B=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=B6=8B=E5=8A=BF=E5=BC=95=E6=93=8E=E7=9A=84=E6=8D=9F?= =?UTF-8?q?=E7=9B=8A=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/order-coordinator.ts | 1 + src/exchanges/grvt/gateway.ts | 2 +- src/exchanges/types.ts | 1 + src/strategy/trend-engine.ts | 23 +++++++++++++++-------- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index e56e603..bff87cc 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -242,6 +242,7 @@ export async function placeStopLossOrder( closePosition: "true", timeInForce: "GTC", quantity: roundQtyDownToStep(quantity, qtyStep), + triggerType: "STOP_LOSS", }; // 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段 params.price = params.stopPrice; diff --git a/src/exchanges/grvt/gateway.ts b/src/exchanges/grvt/gateway.ts index cd4fff1..2412c1f 100644 --- a/src/exchanges/grvt/gateway.ts +++ b/src/exchanges/grvt/gateway.ts @@ -1337,7 +1337,7 @@ function buildUnsignedOrder(params: { function buildTriggerMetadata(params: CreateOrderParams): GrvtUnsignedOrder["metadata"]["trigger"] | undefined { if (params.type === "STOP_MARKET") { - const triggerType = params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"; + const triggerType = params.triggerType ?? (params.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"); const stopPrice = params.stopPrice ?? params.activationPrice; if (!stopPrice) { throw new Error("GRVT stop orders require a stopPrice or activationPrice"); diff --git a/src/exchanges/types.ts b/src/exchanges/types.ts index 6ff56c4..f41eeae 100644 --- a/src/exchanges/types.ts +++ b/src/exchanges/types.ts @@ -21,6 +21,7 @@ export interface CreateOrderParams { timeInForce?: TimeInForce; reduceOnly?: StringBoolean; closePosition?: StringBoolean; + triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS"; } export interface AsterAccountPosition { diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index 6c7254c..3364d11 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -509,10 +509,21 @@ export class TrendEngine { } this.entryPricePendingLogged = false; const direction = position.positionAmt > 0 ? "long" : "short"; + const qtyAbs = Math.abs(position.positionAmt); + const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]); + const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]); + const closeSidePriceRaw = direction === "long" ? depthBid : depthAsk; + const effectiveClosePrice = Number.isFinite(closeSidePriceRaw) + ? closeSidePriceRaw + : Number.isFinite(price) + ? price + : position.entryPrice; const pnl = - (direction === "long" - ? price - position.entryPrice - : position.entryPrice - price) * Math.abs(position.positionAmt); + qtyAbs > 0 + ? (direction === "long" + ? effectiveClosePrice - position.entryPrice + : position.entryPrice - effectiveClosePrice) * qtyAbs + : 0; const unrealized = Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null; @@ -671,11 +682,7 @@ export class TrendEngine { } const derivedLoss = pnl < -this.config.lossLimit; - const snapshotLoss = Boolean( - unrealized != null && - unrealized < -this.config.lossLimit && - pnl <= 0 - ); + const snapshotLoss = derivedLoss; if (derivedLoss || snapshotLoss) { const result = { closed: false, pnl }; From 0d2aa84b8474ebc9b35b23792539bc216a64d606 Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Oct 2025 19:17:24 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20Lighter=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E8=B4=A6=E6=88=B7=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E7=AC=A6=E5=8F=B7=E5=92=8C=E5=B8=82=E5=9C=BA=20ID=20?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/exchanges/lighter/gateway.ts | 8 +++++++- src/exchanges/lighter/mappers.ts | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 98c327f..99a652c 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -660,7 +660,13 @@ export class LighterGateway { private emitAccount(): void { if (!this.accountDetails) return; - const snapshot = toAccountSnapshot(this.displaySymbol, this.accountDetails, this.positions); + const snapshot = toAccountSnapshot( + this.displaySymbol, + this.accountDetails, + this.positions, + [], + { marketSymbol: this.marketSymbol, marketId: this.marketId } + ); this.accountEvent.emit(snapshot); } diff --git a/src/exchanges/lighter/mappers.ts b/src/exchanges/lighter/mappers.ts index b9d46dc..663ff11 100644 --- a/src/exchanges/lighter/mappers.ts +++ b/src/exchanges/lighter/mappers.ts @@ -140,9 +140,21 @@ export function toAccountSnapshot( symbol: string, details: LighterAccountDetails, positions: LighterPosition[] = [], - assets: AsterAccountAsset[] = [] + assets: AsterAccountAsset[] = [], + options?: { marketSymbol?: string | null; marketId?: number | null } ): AsterAccountSnapshot { - const transformedPositions = positions.map((position) => lighterPositionToAster(symbol, position)); + const targetSymbol = options?.marketSymbol?.toUpperCase(); + const targetMarketId = options?.marketId; + const filteredPositions = positions.filter((position) => { + const marketMatches = + targetMarketId == null || + (Number.isFinite(Number(position.market_id)) && Number(position.market_id) === Number(targetMarketId)); + const symbolMatches = + !targetSymbol || + (typeof position.symbol === "string" && position.symbol.toUpperCase() === targetSymbol); + return marketMatches && symbolMatches; + }); + const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position)); const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0); const assetList = assets.length ? assets : defaultAsset(details); return { From d829e451cdcc61155ec13ee8bd343ce82263ef43 Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Oct 2025 20:11:46 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20BackpackGatewa?= =?UTF-8?q?y=20=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E5=BF=AB=E7=85=A7=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=90=88=E7=BA=A6=E5=B8=82=E5=9C=BA=E7=9A=84?= =?UTF-8?q?=E6=8C=81=E4=BB=93=E4=BF=A1=E6=81=AF=E5=92=8C=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/exchanges/backpack/gateway.ts | 201 ++++++++++++++++++++++++++---- tests/backpack-gateway.test.ts | 107 ++++++++++++++++ 2 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 tests/backpack-gateway.test.ts diff --git a/src/exchanges/backpack/gateway.ts b/src/exchanges/backpack/gateway.ts index 4d52bbf..ad88b2a 100644 --- a/src/exchanges/backpack/gateway.ts +++ b/src/exchanges/backpack/gateway.ts @@ -1,6 +1,7 @@ import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt"; import type { AsterAccountSnapshot, + AsterAccountPosition, AsterOrder, AsterDepth, AsterTicker, @@ -30,6 +31,8 @@ export class BackpackGateway { private readonly exchange: any; private readonly symbol: string; private marketSymbol: string; + private market: any | null = null; + private isContractMarket = false; private readonly logger: (context: string, error: unknown) => void; private initialized = false; private initPromise: Promise | null = null; @@ -84,7 +87,7 @@ export class BackpackGateway { private async doInitialize(symbol?: string): Promise { try { await this.exchange.loadMarkets(); - + // Verify symbol exists const requested = (symbol ?? this.symbol).toUpperCase(); const resolved = this.resolveMarketSymbol(requested); @@ -92,7 +95,9 @@ export class BackpackGateway { throw new Error(`Symbol ${requested} not found in Backpack markets`); } this.marketSymbol = resolved; - + this.market = this.exchange.market(this.marketSymbol); + this.isContractMarket = Boolean(this.market?.contract); + this.initialized = true; this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`); } catch (error) { @@ -186,12 +191,11 @@ export class BackpackGateway { // Polling implementations private startAccountPolling(): void { if (this.accountPollTimer) return; - + const poll = async () => { try { - const balance = await this.exchange.fetchBalance(); - const accountSnapshot = this.mapBalanceToAccountSnapshot(balance); - + const accountSnapshot = await this.fetchAccountSnapshot(); + for (const listener of this.accountListeners) { listener(accountSnapshot); } @@ -206,7 +210,7 @@ export class BackpackGateway { private startOrderPolling(): void { if (this.orderPollTimer) return; - + const poll = async () => { try { const [openOrders, closedOrders] = await Promise.all([ @@ -318,7 +322,10 @@ export class BackpackGateway { if (params.reduceOnly !== undefined) { extraParams.reduceOnly = params.reduceOnly === "true"; } - + if (params.closePosition !== undefined) { + extraParams.closePosition = params.closePosition === "true"; + } + const order = await this.exchange.createOrder( symbol, type, @@ -360,30 +367,172 @@ export class BackpackGateway { // Mapping functions private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot { - const positions: any[] = []; // Backpack is spot-only, no positions - const assets: any[] = []; - - for (const [currency, amount] of Object.entries(balance)) { - if (typeof amount === 'object' && amount !== null) { - assets.push({ - asset: currency, - walletBalance: amount.total?.toString() || "0", - availableBalance: amount.free?.toString() || "0", - updateTime: Date.now(), - }); - } - } - - return { + return this.mapBalanceToAccountSnapshotWithPositions(balance, []); + } + + private async fetchAccountSnapshot(): Promise { + await this.ensureInitialized(); + const balancePromise = this.exchange.fetchBalance(); + const positionsPromise = this.isContractMarket + ? this.exchange.fetchPositions([this.marketSymbol]).catch((error: unknown) => { + this.logger("fetchPositions", error); + return []; + }) + : Promise.resolve([]); + + const [balance, positions] = await Promise.all([balancePromise, positionsPromise]); + return this.mapBalanceToAccountSnapshotWithPositions(balance, positions ?? []); + } + + private mapBalanceToAccountSnapshotWithPositions(balance: Balances, rawPositions: any[]): AsterAccountSnapshot { + const now = Date.now(); + const assets = this.normalizeAssets(balance, now); + const positions = this.normalizePositions(rawPositions, now); + + const totalWalletBalance = this.sumStrings(assets.map((asset) => asset.walletBalance)); + const totalUnrealizedProfit = this.sumStrings(positions.map((position) => position.unrealizedProfit ?? "0")); + const availableBalance = this.sumStrings(assets.map((asset) => asset.availableBalance)); + + const snapshot: AsterAccountSnapshot = { canTrade: true, canDeposit: true, canWithdraw: true, - updateTime: Date.now(), - totalWalletBalance: balance.total?.toString() || "0", - totalUnrealizedProfit: "0", + updateTime: now, + totalWalletBalance, + totalUnrealizedProfit, positions, assets, }; + + snapshot.availableBalance = availableBalance; + snapshot.maxWithdrawAmount = availableBalance; + + if (this.isContractMarket) { + const totalMarginBalance = this.addStrings(totalWalletBalance, totalUnrealizedProfit); + snapshot.totalMarginBalance = totalMarginBalance; + snapshot.totalCrossWalletBalance = totalWalletBalance; + snapshot.totalCrossUnPnl = totalUnrealizedProfit; + } + + return snapshot; + } + + private normalizeAssets(balance: Balances, now: number): AsterAccountSnapshot["assets"] { + const metaKeys = new Set(["free", "used", "total", "info", "timestamp", "datetime", "debt"]); + const assets: AsterAccountSnapshot["assets"] = []; + + for (const [currency, value] of Object.entries(balance)) { + if (metaKeys.has(currency)) continue; + if (!value || typeof value !== "object") continue; + + const walletBalance = this.toStringAmount((value as any).total ?? (value as any).free ?? "0"); + const availableBalance = this.toStringAmount((value as any).free ?? "0"); + + assets.push({ + asset: currency, + walletBalance, + availableBalance, + updateTime: now, + }); + } + + return assets; + } + + private normalizePositions(rawPositions: any[], now: number): AsterAccountSnapshot["positions"] { + if (!Array.isArray(rawPositions)) return []; + + const positions: AsterAccountSnapshot["positions"] = []; + + for (const position of rawPositions) { + const info = position?.info ?? position ?? {}; + const rawSymbol = position?.symbol ?? info.symbol ?? this.marketSymbol; + const rawContracts = position?.contracts ?? info.netExposureQuantity; + const derivedSide = (position?.side ?? info.side ?? this.deriveSideFromExposure(info)) ?? "long"; + const rawSide = derivedSide.toString().toLowerCase(); + const quantity = this.toNumber(rawContracts); + if (!quantity) continue; + + const side = rawSide === "short" ? "short" : "long"; + const signedQuantity = side === "short" ? -Math.abs(quantity) : Math.abs(quantity); + + const normalized: AsterAccountPosition = { + symbol: rawSymbol, + positionAmt: signedQuantity.toString(), + entryPrice: this.toStringAmount(position?.entryPrice ?? info.entryPrice ?? "0"), + unrealizedProfit: this.toStringAmount(position?.unrealizedPnl ?? info.pnlUnrealized ?? "0"), + positionSide: side === "short" ? "SHORT" : "LONG", + updateTime: now, + }; + + const markPrice = this.toOptionalString(position?.markPrice ?? info.markPrice); + if (markPrice !== undefined) normalized.markPrice = markPrice; + + const liquidationPrice = this.toOptionalString(position?.liquidationPrice ?? info.estLiquidationPrice); + if (liquidationPrice !== undefined) normalized.liquidationPrice = liquidationPrice; + + const initialMargin = this.toOptionalString(position?.initialMargin ?? info.initialMargin); + if (initialMargin !== undefined) normalized.initialMargin = initialMargin; + + const maintMargin = this.toOptionalString(position?.maintenanceMargin ?? info.maintenanceMargin); + if (maintMargin !== undefined) normalized.maintMargin = maintMargin; + + const leverage = this.toOptionalString(position?.leverage ?? info.leverage); + if (leverage !== undefined) normalized.leverage = leverage; + + normalized.marginType = "CROSSED"; + + positions.push(normalized); + } + + return positions; + } + + private deriveSideFromExposure(info: Record): "long" | "short" | "flat" { + const exposure = this.toNumber(info?.netExposureNotional ?? info?.netCost ?? info?.netQuantity); + if (!exposure) return "flat"; + return exposure < 0 ? "short" : "long"; + } + + private toStringAmount(value: unknown): string { + if (value === undefined || value === null) return "0"; + if (typeof value === "string") { + if (value.trim() === "") return "0"; + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) return "0"; + return value.toString(); + } + return "0"; + } + + private toOptionalString(value: unknown): string | undefined { + const normalized = this.toStringAmount(value); + return normalized === "0" ? undefined : normalized; + } + + private toNumber(value: unknown): number { + const asString = this.toStringAmount(value); + const parsed = Number(asString); + if (!Number.isFinite(parsed)) return 0; + return parsed; + } + + private sumStrings(values: string[]): string { + let total = 0; + for (const value of values) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) continue; + total += parsed; + } + return total.toString(); + } + + private addStrings(a: string, b: string): string { + const sum = Number(a) + Number(b); + if (!Number.isFinite(sum)) return "0"; + return sum.toString(); } private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder { diff --git a/tests/backpack-gateway.test.ts b/tests/backpack-gateway.test.ts new file mode 100644 index 0000000..2ae0fd5 --- /dev/null +++ b/tests/backpack-gateway.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { BackpackGateway } from "../src/exchanges/backpack/gateway"; + +describe("BackpackGateway account snapshots", () => { + const createGateway = () => + new BackpackGateway({ + apiKey: "key", + apiSecret: "secret", + symbol: "BTCUSDC", + logger: () => {}, + }) as any; + + it("maps spot balances without positions", () => { + const gateway = createGateway(); + gateway.isContractMarket = false; + + const balance = { + info: {}, + free: { USDC: "10" }, + used: { USDC: "5" }, + total: { USDC: "15" }, + USDC: { free: "10", used: "5", total: "15" }, + } as any; + + const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, []); + + expect(snapshot.positions).toEqual([]); + expect(snapshot.totalWalletBalance).toBe("15"); + expect(snapshot.totalUnrealizedProfit).toBe("0"); + expect(snapshot.availableBalance).toBe("10"); + expect(snapshot.maxWithdrawAmount).toBe("10"); + expect(snapshot.totalMarginBalance).toBeUndefined(); + expect(snapshot.assets).toHaveLength(1); + expect(snapshot.assets[0]).toMatchObject({ + asset: "USDC", + walletBalance: "15", + availableBalance: "10", + }); + }); + + it("includes derivative positions when present", () => { + const gateway = createGateway(); + gateway.isContractMarket = true; + gateway.marketSymbol = "BTC/USDC:USDC"; + + const balance = { + info: {}, + free: { USDC: "80" }, + used: { USDC: "20" }, + total: { USDC: "100" }, + USDC: { free: "80", used: "20", total: "100" }, + } as any; + + const positions = [ + { + symbol: "BTC/USDC:USDC", + contracts: "2", + side: "long", + entryPrice: "25000", + markPrice: "25200", + unrealizedPnl: "400", + info: { + estLiquidationPrice: "15000", + }, + }, + { + info: { + symbol: "ETH/USDC:USDC", + netExposureQuantity: "0.5", + netCost: "-100", + entryPrice: "3000", + pnlUnrealized: "-10", + markPrice: "2900", + estLiquidationPrice: "1000", + }, + }, + ]; + + const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, positions); + + expect(snapshot.positions).toHaveLength(2); + expect(snapshot.positions[0]).toMatchObject({ + symbol: "BTC/USDC:USDC", + positionAmt: "2", + positionSide: "LONG", + entryPrice: "25000", + unrealizedProfit: "400", + markPrice: "25200", + liquidationPrice: "15000", + }); + expect(snapshot.positions[1]).toMatchObject({ + symbol: "ETH/USDC:USDC", + positionAmt: "-0.5", + positionSide: "SHORT", + entryPrice: "3000", + unrealizedProfit: "-10", + markPrice: "2900", + liquidationPrice: "1000", + }); + + expect(snapshot.totalWalletBalance).toBe("100"); + expect(snapshot.totalUnrealizedProfit).toBe("390"); + expect(snapshot.totalMarginBalance).toBe("490"); + expect(snapshot.totalCrossWalletBalance).toBe("100"); + expect(snapshot.totalCrossUnPnl).toBe("390"); + }); +});