import crypto from "crypto"; import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers"; import type { AccountPosition, AccountSnapshot, Depth, Kline, Order, Ticker, CreateOrderParams, PositionSide, } from "../types"; import type { AsterSpotAccount, AsterSpotAggTrade, AsterSpotBookTicker, AsterSpotCommissionRate, AsterSpotDepth, AsterSpotExchangeInfo, AsterSpotHistoricalTrade, AsterSpotKline, AsterSpotPriceTicker, AsterSpotTicker24h, AsterSpotTrade, AsterSpotUserTrade, AsterFuturesExchangeInfo, AsterFuturesSymbolInfo, CancelSpotOrderParams, CreateSpotOrderParams, QuerySpotOrderParams, SpotAllOrdersParams, SpotOpenOrdersParams, SpotUserTradesParams, } from "./types"; import { decimalsOf } from "../../utils/math"; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; const FUTURES_REST_BASE = "https://fapi.asterdex.com"; const SPOT_REST_BASE = "https://sapi.asterdex.com"; const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws"; const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/"; const FINAL_ORDER_STATUSES = new Set(["FILLED", "CANCELED", "REJECTED", "EXPIRED"]); const DEFAULT_DEPTH_LEVEL = 20; const DEFAULT_DEPTH_SPEED = "100ms"; const DEFAULT_KLINE_LIMIT = 120; const KLINE_REFRESH_INTERVAL_MS = 60_000; const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000; const RECONNECT_DELAY_MS = 2000; const POSITION_SYNC_INTERVAL_MS = 5000; const EXCHANGE_INFO_CACHE_TTL_MS = 60 * 60 * 1000; function requireEnv(value: string | undefined, key: string): string { if (!value) { throw new Error(`Missing required environment variable ${key}`); } return value; } function serialize(params: Record): string { return Object.keys(params) .filter((key) => params[key] !== undefined && params[key] !== null) .sort() .map((key) => `${key}=${encodeURIComponent(String(params[key]))}`) .join("&"); } export class AsterSpotRestClient { private readonly apiKey?: string; private readonly apiSecret?: string; constructor(options: { apiKey?: string; apiSecret?: string } = {}) { this.apiKey = options.apiKey ?? process.env.ASTER_API_KEY; this.apiSecret = options.apiSecret ?? process.env.ASTER_API_SECRET; } async ping(): Promise { await this.request({ path: "/api/v1/ping", method: "GET" }); } async getServerTime(): Promise<{ serverTime: number }> { return this.request<{ serverTime: number }>({ path: "/api/v1/time", method: "GET" }); } async getExchangeInfo(): Promise { return this.request({ path: "/api/v1/exchangeInfo", method: "GET" }); } async getDepth(symbol: string, limit?: number): Promise { const payload = await this.request({ path: "/api/v1/depth", method: "GET", params: { symbol: symbol.toUpperCase(), limit }, }); return { lastUpdateId: Number(payload.lastUpdateId), E: payload.E, T: payload.T, bids: (payload.bids ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["bids"], asks: (payload.asks ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["asks"], }; } async getTrades(symbol: string, limit?: number): Promise { const payload = await this.request({ path: "/api/v1/trades", method: "GET", params: { symbol: symbol.toUpperCase(), limit }, }); return payload.map((item) => ({ id: Number(item.id), price: String(item.price), qty: String(item.qty), baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined, quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined, time: Number(item.time ?? Date.now()), isBuyerMaker: Boolean(item.isBuyerMaker), })); } async getHistoricalTrades(params: { symbol: string; limit?: number; fromId?: number }): Promise { const payload = await this.request({ path: "/api/v1/historicalTrades", method: "GET", params: { symbol: params.symbol.toUpperCase(), limit: params.limit, fromId: params.fromId, }, requiresApiKey: true, }); return payload.map((item) => ({ id: Number(item.id), price: String(item.price), qty: String(item.qty), baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined, quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined, time: Number(item.time ?? Date.now()), isBuyerMaker: Boolean(item.isBuyerMaker), isBestMatch: item.isBestMatch !== undefined ? Boolean(item.isBestMatch) : undefined, })); } async getAggTrades(params: { symbol: string; fromId?: number; startTime?: number; endTime?: number; limit?: number; }): Promise { const payload = await this.request({ path: "/api/v1/aggTrades", method: "GET", params: { symbol: params.symbol.toUpperCase(), fromId: params.fromId, startTime: params.startTime, endTime: params.endTime, limit: params.limit, }, }); return payload.map((item) => ({ a: Number(item.a), p: String(item.p), q: String(item.q), f: Number(item.f), l: Number(item.l), T: Number(item.T), m: Boolean(item.m), M: item.M !== undefined ? Boolean(item.M) : undefined, })); } async getKlines(params: { symbol: string; interval: string; startTime?: number; endTime?: number; limit?: number; }): Promise { const payload = await this.request({ path: "/api/v1/klines", method: "GET", params: { symbol: params.symbol.toUpperCase(), interval: params.interval, startTime: params.startTime, endTime: params.endTime, limit: params.limit, }, }); return payload.map((entry) => ({ openTime: Number(entry[0]), open: String(entry[1]), high: String(entry[2]), low: String(entry[3]), close: String(entry[4]), volume: String(entry[5]), closeTime: Number(entry[6]), quoteAssetVolume: String(entry[7]), numberOfTrades: Number(entry[8] ?? 0), takerBuyBaseAssetVolume: String(entry[9] ?? "0"), takerBuyQuoteAssetVolume: String(entry[10] ?? "0"), })); } async getTicker24h(symbol?: string): Promise { const payload = await this.request({ path: "/api/v1/ticker/24hr", method: "GET", params: symbol ? { symbol: symbol.toUpperCase() } : undefined, }); return this.normalizeTicker24h(payload); } async getTickerPrice(symbol?: string): Promise { const payload = await this.request({ path: "/api/v1/ticker/price", method: "GET", params: symbol ? { symbol: symbol.toUpperCase() } : undefined, }); return Array.isArray(payload) ? payload.map((item) => this.normalizePriceTicker(item)) : this.normalizePriceTicker(payload); } async getBookTicker(symbol?: string): Promise { const payload = await this.request({ path: "/api/v1/ticker/bookTicker", method: "GET", params: symbol ? { symbol: symbol.toUpperCase() } : undefined, }); return Array.isArray(payload) ? payload.map((item) => this.normalizeBookTicker(item)) : this.normalizeBookTicker(payload); } async getCommissionRate(symbol: string, params: { recvWindow?: number } = {}): Promise { const payload = await this.request({ path: "/api/v1/commissionRate", method: "GET", params: { symbol: symbol.toUpperCase(), recvWindow: params.recvWindow }, signed: true, }); return { symbol: payload.symbol, makerCommissionRate: String(payload.makerCommissionRate), takerCommissionRate: String(payload.takerCommissionRate), }; } async createOrder(params: CreateSpotOrderParams): Promise { const response = await this.request({ path: "/api/v1/order", method: "POST", params: this.normalizeSpotOrderParams(params), signed: true, sendInBody: true, }); return toOrderFromRest(response); } async cancelOrder(params: CancelSpotOrderParams): Promise { const response = await this.request({ path: "/api/v1/order", method: "DELETE", params: { symbol: params.symbol.toUpperCase(), orderId: params.orderId, origClientOrderId: params.origClientOrderId, recvWindow: params.recvWindow, }, signed: true, }); return toOrderFromRest(response); } async getOrder(params: QuerySpotOrderParams): Promise { const response = await this.request({ path: "/api/v1/order", method: "GET", params: { symbol: params.symbol.toUpperCase(), orderId: params.orderId, origClientOrderId: params.origClientOrderId, recvWindow: params.recvWindow, }, signed: true, }); return toOrderFromRest(response); } async getOpenOrders(params: SpotOpenOrdersParams = {}): Promise { const response = await this.request({ path: "/api/v1/openOrders", method: "GET", params: { symbol: params.symbol ? params.symbol.toUpperCase() : undefined, recvWindow: params.recvWindow, }, signed: true, }); return response.map(toOrderFromRest); } async cancelAllOpenOrders(params: SpotOpenOrdersParams & { symbol: string }): Promise<{ code: number; msg: string }> { const payload: Record = { symbol: params.symbol.toUpperCase(), recvWindow: params.recvWindow, }; if (params.orderIdList && params.orderIdList.length) { payload.orderIdList = `[${params.orderIdList .map((id) => (typeof id === "string" ? id.trim() : String(id))) .join(",")}]`; } if (params.origClientOrderIdList && params.origClientOrderIdList.length) { payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList); } return this.request<{ code: number; msg: string }>({ path: "/api/v1/allOpenOrders", method: "DELETE", params: payload, signed: true, }); } async getAllOrders(params: SpotAllOrdersParams): Promise { const response = await this.request({ path: "/api/v1/allOrders", method: "GET", params: { symbol: params.symbol.toUpperCase(), orderId: params.orderId, startTime: params.startTime, endTime: params.endTime, limit: params.limit, recvWindow: params.recvWindow, }, signed: true, }); return response.map(toOrderFromRest); } async getAccount(params: { recvWindow?: number } = {}): Promise { const payload = await this.request({ path: "/api/v1/account", method: "GET", params: { recvWindow: params.recvWindow }, signed: true, }); return { ...payload, balances: (payload.balances ?? []).map((balance) => ({ asset: balance.asset, free: String(balance.free ?? "0"), locked: String(balance.locked ?? "0"), })), }; } async getUserTrades(params: SpotUserTradesParams = {}): Promise { const response = await this.request({ path: "/api/v1/userTrades", method: "GET", params: { symbol: params.symbol ? params.symbol.toUpperCase() : undefined, orderId: params.orderId, startTime: params.startTime, endTime: params.endTime, fromId: params.fromId, limit: params.limit, recvWindow: params.recvWindow, }, signed: true, }); return response.map((item) => ({ symbol: item.symbol, id: Number(item.id), orderId: Number(item.orderId), side: item.side, price: String(item.price), qty: String(item.qty), quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined, commission: String(item.commission ?? "0"), commissionAsset: String(item.commissionAsset ?? ""), time: Number(item.time ?? Date.now()), counterpartyId: item.counterpartyId !== undefined ? Number(item.counterpartyId) : undefined, maker: Boolean(item.maker), buyer: Boolean(item.buyer), })); } private normalizeTicker24h(payload: any): AsterSpotTicker24h | AsterSpotTicker24h[] { const mapOne = (entry: any): AsterSpotTicker24h => ({ symbol: entry.symbol, priceChange: String(entry.priceChange), priceChangePercent: String(entry.priceChangePercent), weightedAvgPrice: String(entry.weightedAvgPrice), prevClosePrice: String(entry.prevClosePrice), lastPrice: String(entry.lastPrice), lastQty: String(entry.lastQty), bidPrice: String(entry.bidPrice), bidQty: String(entry.bidQty), askPrice: String(entry.askPrice), askQty: String(entry.askQty), openPrice: String(entry.openPrice), highPrice: String(entry.highPrice), lowPrice: String(entry.lowPrice), volume: String(entry.volume), quoteVolume: String(entry.quoteVolume), openTime: Number(entry.openTime ?? 0), closeTime: Number(entry.closeTime ?? 0), firstId: Number(entry.firstId ?? 0), lastId: Number(entry.lastId ?? 0), count: Number(entry.count ?? 0), baseAsset: entry.baseAsset, quoteAsset: entry.quoteAsset, }); return Array.isArray(payload) ? payload.map((entry) => mapOne(entry)) : mapOne(payload); } private normalizePriceTicker(entry: any): AsterSpotPriceTicker { return { symbol: entry.symbol, price: String(entry.price), time: entry.time !== undefined ? Number(entry.time) : undefined, }; } private normalizeBookTicker(entry: any): AsterSpotBookTicker { return { symbol: entry.symbol, bidPrice: String(entry.bidPrice), bidQty: String(entry.bidQty), askPrice: String(entry.askPrice), askQty: String(entry.askQty), time: entry.time !== undefined ? Number(entry.time) : undefined, }; } private normalizeSpotOrderParams(params: CreateSpotOrderParams): Record { const payload: Record = { symbol: params.symbol.toUpperCase(), side: params.side, type: params.type, timeInForce: params.timeInForce, quantity: params.quantity !== undefined ? params.quantity : undefined, quoteOrderQty: params.quoteOrderQty !== undefined ? params.quoteOrderQty : undefined, price: params.price !== undefined ? params.price : undefined, newClientOrderId: params.newClientOrderId, stopPrice: params.stopPrice !== undefined ? params.stopPrice : undefined, recvWindow: params.recvWindow, }; return payload; } private ensureApiKey(): string { if (!this.apiKey) { throw new Error("[AsterSpotRestClient] Missing API key"); } return this.apiKey; } private ensureCredentials(): { apiKey: string; apiSecret: string } { const apiKey = this.ensureApiKey(); const apiSecret = this.apiSecret; if (!apiSecret) { throw new Error("[AsterSpotRestClient] Missing API secret"); } return { apiKey, apiSecret }; } private cleanParams(params: Record | undefined): Record { const source = params ?? {}; const cleaned: Record = {}; for (const key of Object.keys(source)) { const value = (source as Record)[key]; if (value === undefined || value === null) continue; cleaned[key] = value; } return cleaned; } private async request({ path, method, params, signed = false, sendInBody, requiresApiKey = false, }: { path: string; method: "GET" | "POST" | "DELETE" | "PUT"; params?: Record; signed?: boolean; sendInBody?: boolean; requiresApiKey?: boolean; }): Promise { const cleaned = this.cleanParams(params); const headers: Record = {}; let url = `${SPOT_REST_BASE}${path}`; const useBody = sendInBody ?? (method !== "GET" && method !== "DELETE"); let body: string | undefined; if (requiresApiKey || signed) { headers["X-MBX-APIKEY"] = this.ensureApiKey(); } if (signed) { if (cleaned.timestamp === undefined) cleaned.timestamp = Date.now(); if (cleaned.recvWindow === undefined) cleaned.recvWindow = 5000; const { apiSecret } = this.ensureCredentials(); const serialized = serialize(cleaned); const signature = crypto.createHmac("sha256", apiSecret).update(serialized).digest("hex"); if (useBody) { body = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`; } else { const query = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`; url += url.includes("?") ? `&${query}` : `?${query}`; } } else { const query = serialize(cleaned); if (query) { if (useBody) { body = query; } else { url += url.includes("?") ? `&${query}` : `?${query}`; } } } const init: RequestInit = { method, headers }; if (useBody) { init.body = body ?? ""; headers["Content-Type"] = "application/x-www-form-urlencoded"; } let response: Response; try { response = await fetch(url, init); } catch (error) { throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`); } const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${text}`); } if (!text) { return undefined as T; } try { return JSON.parse(text) as T; } catch { throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`); } } } function toDepth(streamSymbol: string, data: any): Depth { return { eventType: data.e, eventTime: data.E, tradeTime: data.T, symbol: streamSymbol, lastUpdateId: data.u, bids: (data.b ?? []).map(([price, qty]: [string, string]) => [price, qty]), asks: (data.a ?? []).map(([price, qty]: [string, string]) => [price, qty]), }; } function toTicker(data: any): Ticker { return { eventType: data.e, eventTime: data.E, symbol: data.s, lastPrice: data.c, openPrice: data.o, highPrice: data.h, lowPrice: data.l, volume: data.q ?? data.v ?? "0", quoteVolume: data.Q ?? data.V ?? "0", priceChange: data.p, priceChangePercent: data.P, weightedAvgPrice: data.w, lastQty: data.l ?? data.L, openTime: data.O, closeTime: data.C, firstId: data.F, lastId: data.L, count: data.n, }; } function toKline(data: any): Kline { return { eventType: data.e, eventTime: data.E, symbol: data.s, interval: data.k.i, openTime: data.k.t, closeTime: data.k.T, firstTradeId: data.k.f, lastTradeId: data.k.L, open: data.k.o, high: data.k.h, low: data.k.l, close: data.k.c, volume: data.k.v, numberOfTrades: data.k.n, quoteAssetVolume: data.k.q, takerBuyBaseAssetVolume: data.k.V, takerBuyQuoteAssetVolume: data.k.Q, isClosed: Boolean(data.k.x), }; } function fromRestKline(entry: any[], interval: string, symbol: string): Kline { return { eventType: undefined, eventTime: undefined, symbol, interval, openTime: entry[0], open: String(entry[1]), high: String(entry[2]), low: String(entry[3]), close: String(entry[4]), volume: String(entry[5]), closeTime: entry[6], quoteAssetVolume: String(entry[7]), numberOfTrades: Number(entry[8] ?? 0), takerBuyBaseAssetVolume: String(entry[9] ?? "0"), takerBuyQuoteAssetVolume: String(entry[10] ?? "0"), isClosed: Boolean(entry[11]), }; } function toOrderFromRest(raw: any): Order { return { avgPrice: raw.avgPrice ?? "0", clientOrderId: raw.clientOrderId ?? "", cumQuote: raw.cumQuote ?? "0", executedQty: raw.executedQty ?? "0", orderId: raw.orderId, origQty: raw.origQty ?? raw.quantity ?? "0", origType: raw.origType ?? raw.type ?? "", price: raw.price ?? "0", reduceOnly: Boolean(raw.reduceOnly), side: raw.side ?? "", positionSide: raw.positionSide ?? "BOTH", status: raw.status ?? "NEW", stopPrice: raw.stopPrice ?? raw.triggerPrice ?? "0", closePosition: Boolean(raw.closePosition), symbol: raw.symbol ?? "", time: raw.time ?? raw.updateTime ?? Date.now(), timeInForce: raw.timeInForce ?? "GTC", type: raw.type ?? "LIMIT", activatePrice: raw.activatePrice, priceRate: raw.priceRate, updateTime: raw.updateTime ?? Date.now(), workingType: raw.workingType ?? "CONTRACT_PRICE", priceProtect: Boolean(raw.priceProtect), }; } function toOrderFromEvent(event: any): Order { return { avgPrice: event.ap ?? "0", clientOrderId: event.c ?? "", cumQuote: event.z ?? "0", executedQty: event.z ?? "0", orderId: event.i, origQty: event.q ?? "0", origType: event.ot ?? event.o ?? "", price: event.p ?? "0", reduceOnly: Boolean(event.R), side: event.S, positionSide: event.ps ?? "BOTH", status: event.X, stopPrice: event.sp ?? "0", closePosition: Boolean(event.cp), symbol: event.s, time: event.T ?? Date.now(), timeInForce: event.f ?? "GTC", type: event.o ?? "LIMIT", activatePrice: event.AP, priceRate: event.cr, updateTime: event.T ?? Date.now(), workingType: event.wt ?? "CONTRACT_PRICE", priceProtect: Boolean(event.PP), }; } function toPositionFromRisk(raw: any): AccountPosition { const positionSide = String(raw.positionSide ?? raw.ps ?? "BOTH").toUpperCase() as PositionSide; return { symbol: raw.symbol ?? raw.s ?? "", positionAmt: raw.positionAmt ?? raw.pa ?? "0", entryPrice: raw.entryPrice ?? raw.ep ?? "0", unrealizedProfit: raw.unRealizedProfit ?? raw.unrealizedProfit ?? raw.up ?? "0", positionSide, updateTime: raw.updateTime ?? Date.now(), initialMargin: raw.initialMargin ?? raw.positionInitialMargin, maintMargin: raw.maintMargin, positionInitialMargin: raw.positionInitialMargin, openOrderInitialMargin: raw.openOrderInitialMargin, leverage: raw.leverage, isolated: typeof raw.isolated === "boolean" ? raw.isolated : undefined, maxNotional: raw.maxNotionalValue ?? raw.maxNotional, marginType: raw.marginType, isolatedMargin: raw.isolatedMargin, isAutoAddMargin: raw.isAutoAddMargin, liquidationPrice: raw.liquidationPrice, markPrice: raw.markPrice, }; } function deepCloneAccount(snapshot: AccountSnapshot | null): AccountSnapshot | null { return snapshot ? JSON.parse(JSON.stringify(snapshot)) : null; } function sumUnrealizedProfit(positions: AccountPosition[]): string { const total = positions.reduce((acc, position) => acc + Number(position.unrealizedProfit ?? 0), 0); return total.toFixed(8); } function clonePositions(positions: AccountPosition[]): AccountPosition[] { return positions.map((position) => ({ ...position, updateTime: position.updateTime ?? Date.now(), })); } class SimpleEvent { private readonly listeners = new Set<(payload: T) => void>(); add(listener: (payload: T) => void): void { this.listeners.add(listener); } remove(listener: (payload: T) => void): void { this.listeners.delete(listener); } emit(payload: T): void { for (const listener of Array.from(this.listeners)) { try { listener(payload); } catch (error) { console.error("[SimpleEvent] listener failure", error); } } } listenerCount(): number { return this.listeners.size; } } export interface ListenKeyResponse { listenKey: string; } export class AsterRestClient { private readonly apiKey: string; private readonly apiSecret: string; constructor(options: { apiKey?: string; apiSecret?: string } = {}) { this.apiKey = requireEnv(options.apiKey ?? process.env.ASTER_API_KEY, "ASTER_API_KEY"); this.apiSecret = requireEnv(options.apiSecret ?? process.env.ASTER_API_SECRET, "ASTER_API_SECRET"); } async getAccount(): Promise { return this.signedRequest({ path: "/fapi/v2/account", method: "GET", params: {} }); } async getOpenOrders(symbol?: string): Promise { const params: Record = {}; if (symbol) params.symbol = symbol; const raw = await this.signedRequest({ path: "/fapi/v1/openOrders", method: "GET", params }); return raw.map(toOrderFromRest); } async getPositions(symbol?: string): Promise { const params: Record = {}; if (symbol) params.symbol = symbol.toUpperCase(); const raw = await this.signedRequest({ path: "/fapi/v2/positionRisk", method: "GET", params }); return raw.map(toPositionFromRisk); } async getExchangeInfo(): Promise { const url = `${FUTURES_REST_BASE}/fapi/v1/exchangeInfo`; let response: Response; try { response = await fetch(url); } catch (error) { throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`); } const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${text}`); } try { return JSON.parse(text) as AsterFuturesExchangeInfo; } catch { throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`); } } async createOrder(params: CreateOrderParams): Promise { // Sanitize and normalize params for Aster futures API. Paradex-specific flags // like reduceOnly/closePosition on STOP/TRAILING should not leak here. const payload: Record = {}; payload.symbol = String(params.symbol).toUpperCase(); payload.side = params.side; payload.type = params.type; if (params.timeInForce !== undefined) payload.timeInForce = params.timeInForce; if (params.price !== undefined) payload.price = params.price; if (params.stopPrice !== undefined) payload.stopPrice = params.stopPrice; if (params.activationPrice !== undefined) payload.activationPrice = params.activationPrice; if (params.callbackRate !== undefined) payload.callbackRate = params.callbackRate; if (params.quantity !== undefined) payload.quantity = Math.abs(params.quantity); // Aster rejects reduceOnly/closePosition for certain order types (e.g. STOP/TRAILING). // Keep the behavior exchange-specific by stripping them here for Aster. const type = String(params.type).toUpperCase(); const isStopOrTrailing = type === "STOP_MARKET" || type === "TRAILING_STOP_MARKET"; const supportsClosePosition = type === "STOP_MARKET" || type === "TAKE_PROFIT_MARKET"; if (!isStopOrTrailing) { if (params.reduceOnly !== undefined) payload.reduceOnly = params.reduceOnly; } if (supportsClosePosition) { if (params.closePosition !== undefined) payload.closePosition = params.closePosition; } const response = await this.signedRequest({ path: "/fapi/v1/order", method: "POST", params: payload }); return toOrderFromRest(response); } async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise { const response = await this.signedRequest({ path: "/fapi/v1/order", method: "DELETE", params }); return toOrderFromRest(response); } async cancelOrders(params: { symbol: string; orderIdList?: Array; origClientOrderIdList?: string[] }): Promise { const payload: Record = { symbol: params.symbol }; if (params.orderIdList?.length) { payload.orderIdList = `[${params.orderIdList .map((id) => (typeof id === "string" ? id.trim() : String(id))) .join(",")}]`; } if (params.origClientOrderIdList?.length) { payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList); } const response = await this.signedRequest({ path: "/fapi/v1/batchOrders", method: "DELETE", params: payload }); return response.map(toOrderFromRest); } async cancelAllOrders(params: { symbol: string }): Promise { await this.signedRequest({ path: "/fapi/v1/allOpenOrders", method: "DELETE", params }); } async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise { const upper = symbol.toUpperCase(); const url = `${FUTURES_REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`; let response: Response; try { response = await fetch(url); } catch (error) { throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`); } const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${text}`); } try { const payload = JSON.parse(text) as any[]; return payload.map((entry) => fromRestKline(entry, interval, upper)); } catch { throw new Error(`[AsterRestClient] 无法解析K线响应: ${text.slice(0, 200)}`); } } async getPremiumIndex(symbol: string): Promise<{ symbol: string; markPrice?: string; indexPrice?: string; lastFundingRate?: string; fundingRate?: string; nextFundingTime?: number; time?: number; }> { const upper = symbol.toUpperCase(); const url = `${FUTURES_REST_BASE}/fapi/v1/premiumIndex?symbol=${encodeURIComponent(upper)}`; let response: Response; try { response = await fetch(url); } catch (error) { throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`); } const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${text}`); } try { const payload = JSON.parse(text) as any; // The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time } return payload; } catch { throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`); } } async getListenKey(): Promise { const response = await this.signedRequest({ path: "/fapi/v1/listenKey", method: "POST", params: {} }); return response.listenKey; } async keepAliveListenKey(listenKey: string): Promise { await this.signedRequest({ path: "/fapi/v1/listenKey", method: "PUT", params: { listenKey } }); } async closeListenKey(listenKey: string): Promise { await this.signedRequest({ path: "/fapi/v1/listenKey", method: "DELETE", params: { listenKey } }); } private async signedRequest({ path, method, params }: { path: string; method: string; params: Record }): Promise { const timestamp = Date.now(); const payload = { ...params, timestamp, recvWindow: 5000 }; const query = serialize(payload); const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex"); const url = `${FUTURES_REST_BASE}${path}?${query}&signature=${signature}`; const init: RequestInit = { method, headers: { "X-MBX-APIKEY": this.apiKey, "Content-Type": "application/x-www-form-urlencoded", }, }; let response: Response; try { response = await fetch(url, init); } catch (error) { throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`); } const text = await response.text(); if (!response.ok) { throw new Error(`HTTP ${response.status} ${text}`); } try { return JSON.parse(text) as T; } catch { throw new Error(`[AsterRestClient] 无法解析响应: ${text.slice(0, 200)}`); } } } type DepthHandler = (depth: Depth) => void; type TickerHandler = (ticker: Ticker) => void; type KlineHandler = (kline: Kline) => void; type StreamKind = "depth" | "ticker" | "kline"; interface StreamState { stream: string; kind: StreamKind; symbol: string; interval?: string; } export class AsterPublicStreams { private ws: WebSocket | null = null; private reconnectTimeout: ReturnType | null = null; private readonly streams = new Map(); private readonly depthHandlers = new Map>(); private readonly tickerHandlers = new Map>(); private readonly klineHandlers = new Map>(); private nextRequestId = 1; subscribeDepth(symbol: string, handler: DepthHandler): void { const upper = symbol.toUpperCase(); const stream = `${upper.toLowerCase()}@depth${DEFAULT_DEPTH_LEVEL}@${DEFAULT_DEPTH_SPEED}`; this.addHandler(this.depthHandlers, upper, handler); this.registerStream(stream, { stream, kind: "depth", symbol: upper }); } subscribeTicker(symbol: string, handler: TickerHandler): void { const upper = symbol.toUpperCase(); const stream = `${upper.toLowerCase()}@miniTicker`; this.addHandler(this.tickerHandlers, upper, handler); this.registerStream(stream, { stream, kind: "ticker", symbol: upper }); } subscribeKline(symbol: string, interval: string, handler: KlineHandler): void { const upper = symbol.toUpperCase(); const stream = `${upper.toLowerCase()}@kline_${interval}`; this.addHandler(this.klineHandlers, `${upper}:${interval}`, handler); this.registerStream(stream, { stream, kind: "kline", symbol: upper, interval }); } private addHandler(map: Map>, key: string, handler: T): void { let set = map.get(key); if (!set) { set = new Set(); map.set(key, set); } set.add(handler); this.ensureConnection(); } private registerStream(stream: string, state: StreamState): void { if (!this.streams.has(stream)) { this.streams.set(stream, state); this.send({ method: "SUBSCRIBE", params: [stream], id: this.nextRequestId++ }); } } private ensureConnection(): void { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } this.connect(); } private connect(): void { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.ws = new WebSocket(WS_PUBLIC_URL); this.ws.onopen = () => { const streams = Array.from(this.streams.keys()); if (streams.length) { this.send({ method: "SUBSCRIBE", params: streams, id: this.nextRequestId++ }); } }; this.ws.onmessage = (event) => { let payload: any; if (typeof event.data === "string") { try { payload = JSON.parse(event.data); } catch (error) { console.error("[AsterPublicStreams] 无法解析消息", error, event.data); return; } } else { payload = event.data; } if (!payload) return; if (payload.result !== undefined) return; // subscription ack const data = payload.data ?? payload; if (!data.e) return; switch (data.e) { case "depthUpdate": this.dispatchDepth(data); break; case "24hrMiniTicker": this.dispatchTicker(data); break; case "kline": this.dispatchKline(data); break; default: break; } }; this.ws.onclose = () => { this.scheduleReconnect(); }; this.ws.onerror = () => { this.ws?.close(); }; } private scheduleReconnect(): void { if (this.reconnectTimeout) return; this.reconnectTimeout = setTimeout(() => { this.connect(); }, RECONNECT_DELAY_MS); } private send(message: Record): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } private dispatchDepth(data: any): void { const symbol = String(data.s ?? "").toUpperCase(); const handlers = this.depthHandlers.get(symbol); if (!handlers || !handlers.size) return; const depth = toDepth(symbol, data); handlers.forEach((handler) => handler(depth)); } private dispatchTicker(data: any): void { const symbol = String(data.s ?? "").toUpperCase(); const handlers = this.tickerHandlers.get(symbol); if (!handlers || !handlers.size) return; const ticker = toTicker(data); handlers.forEach((handler) => handler(ticker)); } private dispatchKline(data: any): void { const symbol = String(data.s ?? "").toUpperCase(); const interval = data.k?.i ?? ""; const key = `${symbol}:${interval}`; const handlers = this.klineHandlers.get(key); if (!handlers || !handlers.size) return; const kline = toKline(data); handlers.forEach((handler) => handler(kline)); } } interface AccountUpdatePayload { B: Array<{ a: string; wb: string; cw: string; bc: string; wbBalance?: string; } & Record>; P: Array<{ s: string; pa: string; ep: string; cr: string; up: string; mt: string; iw?: string; ps: string; pc?: string; } & Record>; } interface OrderUpdatePayload extends Record {} export class AsterUserStream { private readonly rest: AsterRestClient; private listenKey: string | null = null; private ws: WebSocket | null = null; private keepAliveTimer: ReturnType | null = null; private reconnectTimeout: ReturnType | null = null; private readonly accountEvent = new SimpleEvent<{ eventTime: number; payload: AccountUpdatePayload }>(); private readonly orderEvent = new SimpleEvent<{ eventTime: number; payload: OrderUpdatePayload }>(); private readonly connectEvent = new SimpleEvent(); private isRunning = false; constructor(rest: AsterRestClient) { this.rest = rest; } onAccount(listener: (payload: { eventTime: number; payload: AccountUpdatePayload }) => void): void { this.accountEvent.add(listener); } onOrder(listener: (payload: { eventTime: number; payload: OrderUpdatePayload }) => void): void { this.orderEvent.add(listener); } onConnect(listener: () => void): void { this.connectEvent.add(listener); } async start(): Promise { if (this.isRunning) return; this.isRunning = true; await this.ensureListenKey(); this.openSocket(); this.scheduleKeepAlive(); } stop(): void { this.isRunning = false; if (this.keepAliveTimer) { clearInterval(this.keepAliveTimer); this.keepAliveTimer = null; } if (this.ws) { this.ws.close(); this.ws = null; } if (this.listenKey) { void this.rest.closeListenKey(this.listenKey).catch(() => undefined); this.listenKey = null; } } private async ensureListenKey(): Promise { if (this.listenKey) return; this.listenKey = await this.rest.getListenKey(); } private scheduleKeepAlive(): void { if (this.keepAliveTimer) return; this.keepAliveTimer = setInterval(() => { if (!this.listenKey) return; void this.rest.keepAliveListenKey(this.listenKey).catch((error) => { console.error("[AsterUserStream] keepAlive error", error); }); }, LISTEN_KEY_KEEPALIVE_MS / 2); } private openSocket(): void { if (!this.listenKey) return; if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } const url = `${WS_LISTEN_KEY_URL}${this.listenKey}`; this.ws = new WebSocket(url); this.ws.onopen = () => { this.connectEvent.emit(); }; this.ws.onmessage = (event) => { let payload: any; if (typeof event.data === "string") { try { payload = JSON.parse(event.data); } catch (error) { console.error("[AsterUserStream] 无法解析消息", error, event.data); return; } } else { payload = event.data; } if (!payload) return; if (payload === "ping") { this.ws?.send("pong"); return; } switch (payload.e) { case "ACCOUNT_UPDATE": this.accountEvent.emit({ eventTime: payload.E, payload: payload.a }); break; case "ORDER_TRADE_UPDATE": this.orderEvent.emit({ eventTime: payload.E, payload: payload.o }); break; case "listenKeyExpired": this.handleListenKeyExpired(); break; default: break; } }; this.ws.onclose = () => { this.scheduleReconnect(); }; this.ws.onerror = () => { this.ws?.close(); }; } private async handleListenKeyExpired(): Promise { this.listenKey = null; await this.ensureListenKey(); this.openSocket(); } private scheduleReconnect(): void { if (!this.isRunning) return; if (this.reconnectTimeout) return; this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null; this.openSocket(); }, RECONNECT_DELAY_MS); } } function updateAccountSnapshot(snapshot: AccountSnapshot | null, event: { eventTime: number; payload: AccountUpdatePayload }): AccountSnapshot | null { if (!snapshot) return snapshot; const next = deepCloneAccount(snapshot); if (!next) return snapshot; next.updateTime = event.eventTime; const balances = event.payload.B ?? []; for (const balance of balances) { const asset = balance.a; let assetEntry = next.assets.find((item) => item.asset === asset); if (!assetEntry) { assetEntry = { asset, walletBalance: "0", availableBalance: "0", updateTime: event.eventTime, }; next.assets.push(assetEntry); } if (balance.wb !== undefined) assetEntry.walletBalance = balance.wb; if (balance.cw !== undefined) assetEntry.crossWalletBalance = balance.cw; if (balance.bc !== undefined) assetEntry.availableBalance = balance.bc; assetEntry.updateTime = event.eventTime; } const positions = event.payload.P ?? []; const unrealizedTotals = positions.reduce((acc, item) => acc + parseFloat(item.up ?? "0"), 0); next.totalUnrealizedProfit = unrealizedTotals.toFixed(8); for (const position of positions) { const symbol = position.s; let positionEntry = next.positions.find((item) => item.symbol === symbol && item.positionSide === (position.ps as PositionSide)); if (!positionEntry) { positionEntry = { symbol, positionAmt: "0", entryPrice: "0", unrealizedProfit: "0", positionSide: position.ps as PositionSide, updateTime: event.eventTime, }; next.positions.push(positionEntry); } positionEntry.positionAmt = position.pa ?? positionEntry.positionAmt; positionEntry.entryPrice = position.ep ?? positionEntry.entryPrice; positionEntry.unrealizedProfit = position.up ?? positionEntry.unrealizedProfit; positionEntry.updateTime = event.eventTime; } return next; } function mergeOrderSnapshot(map: Map, order: Order): void { const rawId = order.orderId; if (rawId === undefined || rawId === null) return; const key = String(rawId); if (!key) return; if (FINAL_ORDER_STATUSES.has(order.status)) { map.delete(key); } else { map.set(key, { ...order, orderId: key }); } } export class AsterGateway { private readonly rest: AsterRestClient; private readonly publicStreams: AsterPublicStreams; private readonly userStream: AsterUserStream; private accountSnapshot: AccountSnapshot | null = null; private readonly openOrders = new Map(); private positionSyncTimer: ReturnType | null = null; private positionSyncInFlight = false; private readonly accountEvent = new SimpleEvent(); private readonly ordersEvent = new SimpleEvent(); private readonly depthEvents = new Map>(); private readonly tickerEvents = new Map>(); private readonly klineEvents = new Map>(); private readonly klineStores = new Map(); private readonly klineRefreshTimers = new Map>(); private readonly klineInitialFetches = new Map>(); private initialized = false; private initializing: Promise | null = null; private readonly precisionCache = new Map< string, { priceTick: number; qtyStep: number; priceDecimals?: number; sizeDecimals?: number; } >(); private exchangeInfo: AsterFuturesExchangeInfo | null = null; private exchangeInfoFetchedAt = 0; private exchangeInfoPromise: Promise | null = null; constructor(options: { apiKey?: string; apiSecret?: string } = {}) { this.rest = new AsterRestClient(options); this.publicStreams = new AsterPublicStreams(); this.userStream = new AsterUserStream(this.rest); this.userStream.onAccount((event) => { const updated = updateAccountSnapshot(this.accountSnapshot, event); if (updated) { this.accountSnapshot = updated; this.accountEvent.emit(updated); } }); this.userStream.onOrder((event) => { const order = toOrderFromEvent(event.payload); mergeOrderSnapshot(this.openOrders, order); this.ordersEvent.emit(Array.from(this.openOrders.values())); const execType = typeof event.payload?.x === "string" ? event.payload.x.toUpperCase() : ""; const status = typeof event.payload?.X === "string" ? event.payload.X.toUpperCase() : ""; if (execType === "TRADE" || status === "FILLED" || status === "PARTIALLY_FILLED") { void this.refreshPositions(); } }); this.userStream.onConnect(() => { void this.refreshSnapshots(); }); } async ensureInitialized(_symbol: string): Promise { if (this.initialized) return; if (this.initializing) return this.initializing; this.initializing = (async () => { await this.refreshSnapshots(); this.initialized = true; await this.userStream.start(); this.startPositionSync(); })().catch((error) => { this.initializing = null; throw error; }); return this.initializing; } onAccount(listener: (snapshot: AccountSnapshot) => void): void { this.accountEvent.add(listener); if (this.accountSnapshot) listener(this.accountSnapshot); } onOrders(listener: (orders: Order[]) => void): void { this.ordersEvent.add(listener); listener(Array.from(this.openOrders.values())); } onDepth(symbol: string, listener: (depth: Depth) => void): void { const upper = symbol.toUpperCase(); let event = this.depthEvents.get(upper); if (!event) { event = new SimpleEvent(); this.depthEvents.set(upper, event); this.publicStreams.subscribeDepth(upper, (depth) => { event?.emit(depth); }); } event.add(listener); } onTicker(symbol: string, listener: (ticker: Ticker) => void): void { const upper = symbol.toUpperCase(); let event = this.tickerEvents.get(upper); if (!event) { event = new SimpleEvent(); this.tickerEvents.set(upper, event); this.publicStreams.subscribeTicker(upper, (ticker) => { event?.emit(ticker); }); } event.add(listener); } onKlines(symbol: string, interval: string, listener: (klines: Kline[]) => void): void { const upper = symbol.toUpperCase(); const key = `${upper}:${interval}`; let event = this.klineEvents.get(key); if (!event) { event = new SimpleEvent(); this.klineEvents.set(key, event); this.publicStreams.subscribeKline(symbol, interval, (kline) => { const storeKey = `${upper}:${interval}`; let store = this.klineStores.get(storeKey); if (!store) { store = []; this.klineStores.set(storeKey, store); } const index = store.findIndex((item) => item.openTime === kline.openTime); if (index >= 0) { store[index] = kline; } else { store.push(kline); store.sort((a, b) => a.openTime - b.openTime); if (store.length > DEFAULT_KLINE_LIMIT) { store.shift(); } } event?.emit([...store]); }); void this.ensureKlineSeed(upper, interval); } event.add(listener); const existing = this.klineStores.get(key); if (existing && existing.length) { listener([...existing]); } else { void this.ensureKlineSeed(upper, interval); } } private ensureKlineSeed(symbol: string, interval: string): Promise { const key = `${symbol}:${interval}`; const existing = this.klineInitialFetches.get(key); if (existing) return existing; const task = (async () => { try { const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT); klines.sort((a, b) => a.openTime - b.openTime); this.klineStores.set(key, klines); const event = this.klineEvents.get(key); if (event) { event.emit([...klines]); } } catch (error) { console.error("[AsterGateway] seed klines failed", error); } finally { this.startKlineRefresh(symbol, interval); } })(); this.klineInitialFetches.set(key, task); return task; } private startKlineRefresh(symbol: string, interval: string): void { const key = `${symbol}:${interval}`; if (this.klineRefreshTimers.has(key)) return; const timer = setInterval(async () => { try { const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT); klines.sort((a, b) => a.openTime - b.openTime); this.klineStores.set(key, klines); const event = this.klineEvents.get(key); if (event) { event.emit([...klines]); } } catch (error) { console.error("[AsterGateway] refresh klines failed", error); } }, KLINE_REFRESH_INTERVAL_MS); this.klineRefreshTimers.set(key, timer); } private async refreshSnapshots(): Promise { try { const account = await this.rest.getAccount(); let positions = account.positions ?? []; try { const latestPositions = await this.rest.getPositions(); if (Array.isArray(latestPositions) && latestPositions.length) { positions = latestPositions; } } catch (positionError) { console.error("[AsterGateway] 刷新持仓失败", positionError); } const normalizedPositions = clonePositions(positions); const snapshot: AccountSnapshot = { ...account, positions: normalizedPositions, totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions), updateTime: Date.now(), }; this.accountSnapshot = snapshot; this.accountEvent.emit(snapshot); } catch (error) { console.error("[AsterGateway] 刷新账户信息失败", error); } try { const orders = await this.rest.getOpenOrders(); this.openOrders.clear(); orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order)); this.ordersEvent.emit(Array.from(this.openOrders.values())); } catch (error) { console.error("[AsterGateway] 刷新挂单失败", error); } } private startPositionSync(): void { if (this.positionSyncTimer) return; const tick = () => { void this.refreshPositions(); }; void this.refreshPositions(); this.positionSyncTimer = setInterval(tick, POSITION_SYNC_INTERVAL_MS); } private async refreshPositions(): Promise { if (this.positionSyncInFlight) return; this.positionSyncInFlight = true; try { const positions = await this.rest.getPositions(); if (!Array.isArray(positions)) return; const normalizedPositions = clonePositions(positions); if (!this.accountSnapshot) { const snapshot: AccountSnapshot = { canTrade: true, canDeposit: true, canWithdraw: true, updateTime: Date.now(), totalWalletBalance: "0", totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions), positions: normalizedPositions, assets: [], }; this.accountSnapshot = snapshot; this.accountEvent.emit(snapshot); return; } const nextSnapshot: AccountSnapshot = { ...this.accountSnapshot, positions: normalizedPositions, totalUnrealizedProfit: sumUnrealizedProfit(normalizedPositions), updateTime: Date.now(), }; this.accountSnapshot = nextSnapshot; this.accountEvent.emit(nextSnapshot); } catch (error) { console.error("[AsterGateway] 同步持仓失败", error); } finally { this.positionSyncInFlight = false; } } getAccountSnapshot(): AccountSnapshot | null { return this.accountSnapshot; } getOpenOrdersSnapshot(): Order[] { return Array.from(this.openOrders.values()); } async createOrder(params: CreateOrderParams): Promise { const normalized = await this.normalizeOrderParams(params); const order = await this.rest.createOrder(normalized); mergeOrderSnapshot(this.openOrders, order); this.ordersEvent.emit(Array.from(this.openOrders.values())); return order; } async getPrecision(symbol: string): Promise<{ priceTick: number; qtyStep: number; priceDecimals?: number; sizeDecimals?: number; } | null> { const upper = String(symbol).toUpperCase(); const cached = this.precisionCache.get(upper); if (cached) return cached; let exchangeInfo: AsterFuturesExchangeInfo; try { exchangeInfo = await this.loadExchangeInfo(); } catch (error) { console.error("[AsterGateway] 获取交易规则失败", error); return null; } const symbols = exchangeInfo?.symbols ?? []; const match = symbols.find((item) => { if (!item) return false; const symbolName = typeof item.symbol === "string" ? item.symbol.toUpperCase() : ""; const pairName = typeof item.pair === "string" ? item.pair.toUpperCase() : ""; return symbolName === upper || pairName === upper; }); if (!match) return null; const precision = this.extractSymbolPrecision(match); this.precisionCache.set(upper, precision); return precision; } async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise { const result = await this.rest.cancelOrder(params); mergeOrderSnapshot(this.openOrders, result); this.ordersEvent.emit(Array.from(this.openOrders.values())); } async cancelOrders(params: { symbol: string; orderIdList?: Array; origClientOrderIdList?: string[] }): Promise { const results = await this.rest.cancelOrders(params); results.forEach((order) => mergeOrderSnapshot(this.openOrders, order)); this.ordersEvent.emit(Array.from(this.openOrders.values())); } async cancelAllOrders(params: { symbol: string }): Promise { await this.rest.cancelAllOrders(params); for (const [key, order] of Array.from(this.openOrders.entries())) { if (order.symbol === params.symbol) { this.openOrders.delete(key); } } this.ordersEvent.emit(Array.from(this.openOrders.values())); } private async normalizeOrderParams(params: CreateOrderParams): Promise { const symbol = String(params.symbol).toUpperCase(); const precision = await this.getPrecision(symbol); if (!precision) { return { ...params, symbol }; } const { priceTick, qtyStep, priceDecimals, sizeDecimals } = precision; const normalized: CreateOrderParams = { ...params, symbol }; if (normalized.price !== undefined) { normalized.price = this.quantizePrice(normalized.price, priceTick, priceDecimals); } if (normalized.stopPrice !== undefined) { normalized.stopPrice = this.quantizePrice(normalized.stopPrice, priceTick, priceDecimals); } if (normalized.activationPrice !== undefined) { normalized.activationPrice = this.quantizePrice(normalized.activationPrice, priceTick, priceDecimals); } if (normalized.quantity !== undefined) { normalized.quantity = this.quantizeQuantity(Math.abs(normalized.quantity), qtyStep, sizeDecimals); } return normalized; } private async loadExchangeInfo(): Promise { const now = Date.now(); if (this.exchangeInfo && now - this.exchangeInfoFetchedAt <= EXCHANGE_INFO_CACHE_TTL_MS) { return this.exchangeInfo; } if (this.exchangeInfoPromise) { return this.exchangeInfoPromise; } this.exchangeInfoPromise = this.rest .getExchangeInfo() .then((info) => { this.exchangeInfo = info; this.exchangeInfoFetchedAt = Date.now(); this.exchangeInfoPromise = null; return info; }) .catch((error) => { this.exchangeInfoPromise = null; throw error; }); return this.exchangeInfoPromise; } private extractSymbolPrecision(symbolInfo: AsterFuturesSymbolInfo): { priceTick: number; qtyStep: number; priceDecimals?: number; sizeDecimals?: number; } { const filters = symbolInfo.filters ?? []; const normalizeFilterType = (type: string) => filters.find((item) => typeof item.filterType === "string" && item.filterType.toUpperCase() === type); const parseNumber = (value: unknown): number | undefined => { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string") { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; }; const priceFilter = normalizeFilterType("PRICE_FILTER"); const lotFilter = normalizeFilterType("LOT_SIZE"); const marketLotFilter = normalizeFilterType("MARKET_LOT_SIZE"); const tickSize = parseNumber(priceFilter?.tickSize); const stepSize = parseNumber(lotFilter?.stepSize ?? marketLotFilter?.stepSize); const priceDecimals = typeof symbolInfo.pricePrecision === "number" && Number.isFinite(symbolInfo.pricePrecision) ? symbolInfo.pricePrecision : typeof symbolInfo.quotePrecision === "number" && Number.isFinite(symbolInfo.quotePrecision) ? symbolInfo.quotePrecision : undefined; const sizeDecimals = typeof symbolInfo.quantityPrecision === "number" && Number.isFinite(symbolInfo.quantityPrecision) ? symbolInfo.quantityPrecision : typeof symbolInfo.baseAssetPrecision === "number" && Number.isFinite(symbolInfo.baseAssetPrecision) ? symbolInfo.baseAssetPrecision : undefined; return { priceTick: this.ensurePositivePrecision(tickSize, priceDecimals), qtyStep: this.ensurePositivePrecision(stepSize, sizeDecimals), priceDecimals, sizeDecimals, }; } private ensurePositivePrecision(value: number | undefined, decimals?: number): number { if (typeof value === "number" && Number.isFinite(value) && value > 0) { const digits = Math.max(0, decimals ?? decimalsOf(value)); return Number(value.toFixed(digits)); } if (typeof decimals === "number" && decimals >= 0) { const fallback = Math.pow(10, -decimals); const digits = Math.max(0, decimals); return Number(fallback.toFixed(digits)); } return 0; } private quantizePrice(value: number, tick: number, decimals?: number): number { if (!Number.isFinite(value)) return value; let result = value; if (Number.isFinite(tick) && tick > 0) { const ratio = value / tick; const rounded = Math.round(ratio); const quantized = rounded * tick; const digits = Math.max(0, decimals ?? decimalsOf(tick)); result = Number(quantized.toFixed(digits)); } else if (typeof decimals === "number" && decimals >= 0) { result = Number(value.toFixed(decimals)); } if (typeof decimals === "number" && decimals >= 0) { result = Number(result.toFixed(decimals)); } return result; } private quantizeQuantity(value: number, step: number, decimals?: number): number { if (!Number.isFinite(value)) return value; const absValue = Math.abs(value); let result = absValue; if (Number.isFinite(step) && step > 0) { const ratio = absValue / step; const floored = Math.floor(ratio + 1e-12) * step; const digits = Math.max(0, decimals ?? decimalsOf(step)); result = Number(floored.toFixed(digits)); if (result <= 0 && absValue > 0) { const fallback = Number(step.toFixed(digits)); if (fallback > 0) { result = fallback; } } } else if (typeof decimals === "number" && decimals >= 0) { result = Number(absValue.toFixed(decimals)); } if (typeof decimals === "number" && decimals >= 0) { result = Number(result.toFixed(decimals)); } return result; } }