diff --git a/src/exchanges/aster/client.ts b/src/exchanges/aster/client.ts new file mode 100644 index 0000000..c9a57f0 --- /dev/null +++ b/src/exchanges/aster/client.ts @@ -0,0 +1,1790 @@ +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; + } +}