From d925fd93ee69a40d73dd2ac27de8aebd91599d4f Mon Sep 17 00:00:00 2001 From: discountry Date: Mon, 6 Apr 2026 18:54:56 +0800 Subject: [PATCH] refactor: update import paths for AsterSpotBookTicker type - Moved AsterSpotBookTicker import to the correct module path from "../exchanges/aster/types". - Cleaned up imports in basis-arb-engine.ts for better organization and clarity. --- src/exchanges/aster/client.ts | 1790 ------------------------------ src/strategy/basis-arb-engine.ts | 3 +- 2 files changed, 2 insertions(+), 1791 deletions(-) delete mode 100644 src/exchanges/aster/client.ts diff --git a/src/exchanges/aster/client.ts b/src/exchanges/aster/client.ts deleted file mode 100644 index c9a57f0..0000000 --- a/src/exchanges/aster/client.ts +++ /dev/null @@ -1,1790 +0,0 @@ -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; - } -} diff --git a/src/strategy/basis-arb-engine.ts b/src/strategy/basis-arb-engine.ts index c062b8f..6769569 100644 --- a/src/strategy/basis-arb-engine.ts +++ b/src/strategy/basis-arb-engine.ts @@ -1,6 +1,7 @@ import type { BasisArbConfig } from "../config"; import type { ExchangeAdapter, FundingRateSnapshot } from "../exchanges/adapter"; -import type { AccountSnapshot, Depth, AsterSpotBookTicker } from "../exchanges/types"; +import type { AccountSnapshot, Depth } from "../exchanges/types"; +import type { AsterSpotBookTicker } from "../exchanges/aster/types"; import { AsterSpotRestClient, AsterRestClient } from "../exchanges/aster/gateway"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; import { StrategyEventEmitter } from "./common/event-emitter";