Files
ritmex-bot/src/exchanges/binance/gateway.ts
T
discountry 345ad11ba3 refactor: enhance order handling and tracking in GridEngine
- Introduced a clientOrderId system for better order identification.
- Updated order creation logic to ensure unique clientOrderIds.
- Improved order cancellation methods to maintain accurate current orders.
- Added tests for new clientOrderId functionality and level state tracking.
- Ensured that desired orders have an intent field set for clarity.
- Enhanced snapshot functionality to include level states for grid lines.
2026-04-06 20:05:03 +08:00

1704 lines
57 KiB
TypeScript

import ccxt, {
type Balances,
type Order as CcxtOrder,
} from "ccxt";
import axios from "axios";
import { createHash } from "crypto";
import NodeWebSocket from "ws";
import type {
AccountAsset,
AccountPosition,
AccountSnapshot,
Depth,
Kline,
Order,
Ticker,
CreateOrderParams,
OrderType,
PositionSide,
TimeInForce,
} from "../types";
import type {
AccountListener,
DepthListener,
ExchangePrecision,
FundingRateListener,
KlineListener,
OrderListener,
TickerListener,
} from "../adapter";
import { extractMessage } from "../../utils/errors";
const WebSocketCtor: typeof globalThis.WebSocket =
typeof globalThis.WebSocket !== "undefined"
? globalThis.WebSocket
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
const DEFAULT_SPOT_REST_URL = "https://api.binance.com";
const DEFAULT_FUTURES_REST_URL = "https://fapi.binance.com";
const DEFAULT_SPOT_WS_URL = "wss://stream.binance.com:9443/ws";
const DEFAULT_FUTURES_WS_URL = "wss://fstream.binance.com/ws";
const RECONNECT_BASE_DELAY_MS = 3000;
const RECONNECT_MAX_DELAY_MS = 60_000;
const LISTEN_KEY_KEEPALIVE_MS = 25 * 60 * 1000;
type MarketKind = "spot" | "perp";
interface BinanceMarketRef {
kind: MarketKind;
symbol: string;
id: string;
base?: string;
quote?: string;
}
interface PublicSubscription<T> {
kind: MarketKind;
stream: string;
listeners: Set<(payload: T) => void>;
transform: (payload: unknown) => T | null;
ws: WebSocket | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
reconnectDelayMs: number;
}
interface UserStreamState {
kind: MarketKind;
active: boolean;
listenKey: string | null;
ws: WebSocket | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
reconnectDelayMs: number;
keepaliveTimer: ReturnType<typeof setInterval> | null;
}
export interface BinanceGatewayOptions {
apiKey: string;
apiSecret: string;
symbol: string;
marketType?: "spot" | "perp" | "auto";
sandbox?: boolean;
spotRestUrl?: string;
futuresRestUrl?: string;
spotWsUrl?: string;
futuresWsUrl?: string;
logger?: (context: string, error: unknown) => void;
pollIntervals?: {
account?: number;
orders?: number;
};
}
interface ParsedSymbolHint {
normalized: string;
forcedKind?: MarketKind;
}
const FUTURES_SUFFIX = /(?:[_-]?PERP|[_-]?USDM)$/i;
const SPOT_SUFFIX = /(?:[_-]?SPOT)$/i;
function normalizeMode(value: string | undefined): "spot" | "perp" | "auto" {
const raw = (value ?? "").trim().toLowerCase();
if (raw === "spot") return "spot";
if (raw === "auto") return "auto";
return "perp";
}
function normalizeSymbolKey(value: string): string {
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
function parseSymbolHint(raw: string): ParsedSymbolHint {
const source = (raw ?? "").trim().toUpperCase();
if (!source) return { normalized: "" };
if (source.includes(":") || source.includes("PERP")) {
return {
normalized: source.replace(FUTURES_SUFFIX, ""),
forcedKind: "perp",
};
}
if (SPOT_SUFFIX.test(source)) {
return {
normalized: source.replace(SPOT_SUFFIX, ""),
forcedKind: "spot",
};
}
if (FUTURES_SUFFIX.test(source)) {
return {
normalized: source.replace(FUTURES_SUFFIX, ""),
forcedKind: "perp",
};
}
return { normalized: source };
}
function normalizeTimeInForce(value: unknown): TimeInForce | undefined {
const upper = String(value ?? "").toUpperCase();
if (upper === "GTC" || upper === "IOC" || upper === "FOK" || upper === "GTX") {
return upper;
}
return undefined;
}
function normalizePositionSide(value: unknown): PositionSide {
const upper = String(value ?? "").toUpperCase();
if (upper === "LONG") return "LONG";
if (upper === "SHORT") return "SHORT";
return "BOTH";
}
function toNumber(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function hashOrderKey(kind: MarketKind, symbol: string, orderId: string): string {
const digest = createHash("sha1")
.update(`${kind}:${symbol}:${orderId}`)
.digest("hex");
return `${kind}:${digest}`;
}
function decodeWsData(data: unknown): unknown {
if (typeof data === "string") {
try {
return JSON.parse(data);
} catch {
return null;
}
}
if (data instanceof ArrayBuffer) {
try {
return JSON.parse(Buffer.from(data).toString("utf8"));
} catch {
return null;
}
}
if (ArrayBuffer.isView(data)) {
try {
return JSON.parse(Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"));
} catch {
return null;
}
}
if (data && typeof data === "object") {
return data;
}
return null;
}
export class BinanceGateway {
private readonly apiKey: string;
private readonly apiSecret: string;
private readonly defaultSymbol: string;
private readonly requestedMode: "spot" | "perp" | "auto";
private readonly logger: (context: string, error: unknown) => void;
private readonly pollIntervals: { account: number; orders: number };
private readonly spotRestUrl: string;
private readonly futuresRestUrl: string;
private readonly spotWsUrl: string;
private readonly futuresWsUrl: string;
private readonly spotExchange: any;
private readonly perpExchange: any;
private initialized = false;
private initPromise: Promise<void> | null = null;
private destroyed = false;
private defaultKind: MarketKind = "perp";
private defaultMarket: BinanceMarketRef | null = null;
private readonly marketIndex: Record<MarketKind, Map<string, BinanceMarketRef>> = {
spot: new Map(),
perp: new Map(),
};
private readonly symbolAliasByKey = new Map<string, string>();
private readonly activeKinds = new Set<MarketKind>();
private readonly accountListeners = new Set<AccountListener>();
private readonly orderListeners = new Set<OrderListener>();
private readonly depthSubs = new Map<string, PublicSubscription<Depth>>();
private readonly tickerSubs = new Map<string, PublicSubscription<Ticker>>();
private readonly klineSubs = new Map<string, PublicSubscription<Kline[]>>();
private readonly fundingSubs = new Map<string, PublicSubscription<{ symbol: string; fundingRate: number; updateTime: number }>>();
private accountPollTimer: ReturnType<typeof setInterval> | null = null;
private orderPollTimer: ReturnType<typeof setInterval> | null = null;
private readonly userStreams: Record<MarketKind, UserStreamState> = {
spot: {
kind: "spot",
active: false,
listenKey: null,
ws: null,
reconnectTimer: null,
reconnectDelayMs: RECONNECT_BASE_DELAY_MS,
keepaliveTimer: null,
},
perp: {
kind: "perp",
active: false,
listenKey: null,
ws: null,
reconnectTimer: null,
reconnectDelayMs: RECONNECT_BASE_DELAY_MS,
keepaliveTimer: null,
},
};
private readonly localOrders = new Map<string, Order>();
private readonly spotBalances = new Map<string, { free: number; locked: number }>();
private readonly perpBalances = new Map<string, { wallet: number; available: number }>();
private readonly perpPositions = new Map<string, AccountPosition>();
private readonly lastMarkPriceBySymbol = new Map<string, number>();
private lastSpotSnapshot: AccountSnapshot | null = null;
private lastPerpSnapshot: AccountSnapshot | null = null;
constructor(options: BinanceGatewayOptions) {
this.apiKey = options.apiKey;
this.apiSecret = options.apiSecret;
this.defaultSymbol = (options.symbol ?? process.env.BINANCE_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTCUSDT").trim().toUpperCase();
this.requestedMode = normalizeMode(options.marketType ?? process.env.BINANCE_MARKET_TYPE);
this.logger = options.logger ?? ((context, error) => console.error(`[BinanceGateway] ${context}:`, error));
this.pollIntervals = {
account: Math.max(1000, Number(options.pollIntervals?.account ?? process.env.BINANCE_ACCOUNT_POLL_MS ?? 5000)),
orders: Math.max(1000, Number(options.pollIntervals?.orders ?? process.env.BINANCE_ORDERS_POLL_MS ?? 3000)),
};
this.spotRestUrl = (options.spotRestUrl ?? process.env.BINANCE_SPOT_REST_URL ?? DEFAULT_SPOT_REST_URL).replace(/\/+$/, "");
this.futuresRestUrl = (options.futuresRestUrl ?? process.env.BINANCE_FUTURES_REST_URL ?? DEFAULT_FUTURES_REST_URL).replace(/\/+$/, "");
this.spotWsUrl = (options.spotWsUrl ?? process.env.BINANCE_SPOT_WS_URL ?? DEFAULT_SPOT_WS_URL).replace(/\/+$/, "");
this.futuresWsUrl = (options.futuresWsUrl ?? process.env.BINANCE_FUTURES_WS_URL ?? DEFAULT_FUTURES_WS_URL).replace(/\/+$/, "");
this.spotExchange = new (ccxt as any).binance({
apiKey: this.apiKey,
secret: this.apiSecret,
enableRateLimit: true,
timeout: 30_000,
options: { defaultType: "spot" },
});
this.perpExchange = new (ccxt as any).binanceusdm({
apiKey: this.apiKey,
secret: this.apiSecret,
enableRateLimit: true,
timeout: 30_000,
options: { defaultType: "future" },
});
const sandboxRaw = String(options.sandbox ?? process.env.BINANCE_SANDBOX ?? "false").trim().toLowerCase();
if (sandboxRaw === "1" || sandboxRaw === "true" || sandboxRaw === "yes" || sandboxRaw === "on") {
try {
this.spotExchange.setSandboxMode(true);
} catch (error) {
this.logger("spotSandbox", error);
}
try {
this.perpExchange.setSandboxMode(true);
} catch (error) {
this.logger("perpSandbox", error);
}
}
}
async ensureInitialized(symbol?: string): Promise<void> {
if (this.destroyed) {
throw new Error("Binance gateway destroyed");
}
if (this.initialized) {
if (symbol) this.resolveMarket(symbol);
return;
}
if (this.initPromise) {
await this.initPromise;
if (symbol) this.resolveMarket(symbol);
return;
}
this.initPromise = this.doInitialize()
.then(() => {
this.initialized = true;
})
.catch((error) => {
this.initPromise = null;
throw error;
});
await this.initPromise;
if (symbol) this.resolveMarket(symbol);
}
private async doInitialize(): Promise<void> {
try {
await Promise.all([this.spotExchange.loadMarkets(), this.perpExchange.loadMarkets()]);
this.rebuildMarketIndex("spot");
this.rebuildMarketIndex("perp");
const preferredKind = this.requestedMode === "auto" ? undefined : this.requestedMode;
const resolved = this.resolveMarket(this.defaultSymbol, preferredKind);
this.defaultKind = resolved.kind;
this.defaultMarket = resolved;
this.activeKinds.add(resolved.kind);
this.registerAlias(resolved.kind, resolved.id, this.defaultSymbol);
} catch (error) {
this.logger("initialize", error);
throw error;
}
}
destroy(): void {
this.destroyed = true;
this.stopPolling();
this.closeAllSubscriptions(this.depthSubs);
this.closeAllSubscriptions(this.tickerSubs);
this.closeAllSubscriptions(this.klineSubs);
this.closeAllSubscriptions(this.fundingSubs);
for (const kind of ["spot", "perp"] as const) {
this.stopUserStream(kind);
}
for (const client of [this.spotExchange, this.perpExchange]) {
if (typeof client.close === "function") {
try {
void client.close();
} catch (error) {
this.logger("destroy", error);
}
}
}
}
private closeAllSubscriptions<T>(map: Map<string, PublicSubscription<T>>): void {
for (const sub of map.values()) {
this.closeSubscription(sub);
}
map.clear();
}
onAccount(cb: AccountListener): void {
this.accountListeners.add(cb);
const snapshot = this.buildCombinedAccountSnapshot();
if (snapshot) {
try {
cb(snapshot);
} catch (error) {
this.logger("accountReplay", error);
}
}
this.ensurePrivateStreaming();
this.startAccountPolling();
}
onOrders(cb: OrderListener): void {
this.orderListeners.add(cb);
this.emitOrders();
this.ensurePrivateStreaming();
this.startOrderPolling();
}
onDepth(symbol: string, cb: DepthListener): void {
const market = this.resolveMarket(symbol);
this.activateKind(market.kind);
const key = `${market.kind}:${market.id}:depth`;
const stream = `${market.id.toLowerCase()}@depth@100ms`;
const sub = this.ensureSubscription(
this.depthSubs,
key,
market.kind,
stream,
(payload) => this.mapDepthPayload(payload, market)
);
sub.listeners.add(cb);
}
onTicker(symbol: string, cb: TickerListener): void {
const market = this.resolveMarket(symbol);
this.activateKind(market.kind);
const key = `${market.kind}:${market.id}:ticker`;
const stream = `${market.id.toLowerCase()}@ticker`;
const sub = this.ensureSubscription(
this.tickerSubs,
key,
market.kind,
stream,
(payload) => this.mapTickerPayload(payload, market)
);
sub.listeners.add(cb);
}
onKlines(symbol: string, interval: string, cb: KlineListener): void {
const market = this.resolveMarket(symbol);
this.activateKind(market.kind);
const normalizedInterval = (interval ?? "1m").trim();
const key = `${market.kind}:${market.id}:kline:${normalizedInterval}`;
const stream = `${market.id.toLowerCase()}@kline_${normalizedInterval}`;
const sub = this.ensureSubscription(
this.klineSubs,
key,
market.kind,
stream,
(payload) => this.mapKlinePayload(payload, normalizedInterval)
);
sub.listeners.add(cb);
}
onFundingRate(symbol: string, cb: FundingRateListener): void {
const market = this.resolveMarket(symbol, "perp");
this.activateKind(market.kind);
const key = `${market.kind}:${market.id}:funding`;
const stream = `${market.id.toLowerCase()}@markPrice@1s`;
const sub = this.ensureSubscription(
this.fundingSubs,
key,
market.kind,
stream,
(payload) => this.mapFundingPayload(payload, market)
);
sub.listeners.add((snapshot) => {
cb(snapshot);
});
}
async createOrder(params: CreateOrderParams): Promise<Order> {
await this.ensureInitialized(params.symbol);
const market = this.resolveMarket(params.symbol);
const exchange = this.getExchange(market.kind);
let type = this.mapOrderTypeToCcxt(params.type, market.kind);
const side = params.side.toLowerCase();
const amount = params.quantity;
const price = params.price;
const extra: Record<string, unknown> = {};
if (params.timeInForce) {
extra.timeInForce = params.timeInForce;
}
if (params.stopPrice != null) {
extra.stopPrice = params.stopPrice;
}
if (params.activationPrice != null) {
extra.activationPrice = params.activationPrice;
}
if (params.callbackRate != null) {
extra.callbackRate = params.callbackRate;
}
if (params.clientOrderId != null) {
extra.newClientOrderId = params.clientOrderId;
}
if (market.kind === "perp") {
if (params.reduceOnly != null) {
extra.reduceOnly = params.reduceOnly === "true";
}
if (params.closePosition != null) {
extra.closePosition = params.closePosition === "true";
}
if (params.triggerType === "STOP_LOSS") {
extra.workingType = "MARK_PRICE";
}
} else {
if (type === "limit" && params.timeInForce === "GTX") {
type = "limit_maker";
delete extra.timeInForce;
}
}
try {
const raw = (await exchange.createOrder(market.symbol, type, side, amount, price, extra)) as CcxtOrder;
const mapped = this.mapCcxtOrder(raw, market.kind, market.id);
this.upsertOrder(mapped, market.kind, market.id);
return mapped;
} catch (error) {
throw new Error(`Binance createOrder failed: ${extractMessage(error)}`);
}
}
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
await this.ensureInitialized(params.symbol);
const market = this.resolveMarket(params.symbol);
const exchange = this.getExchange(market.kind);
try {
await exchange.cancelOrder(params.orderId, market.symbol);
this.removeOrder(market.kind, market.id, String(params.orderId));
} catch (error) {
throw new Error(`Binance cancelOrder failed: ${extractMessage(error)}`);
}
}
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
await this.ensureInitialized(params.symbol);
const market = this.resolveMarket(params.symbol);
const exchange = this.getExchange(market.kind);
const errors: Array<{ orderId: number | string; error: unknown }> = [];
await Promise.all(
params.orderIdList.map(async (orderId) => {
try {
await exchange.cancelOrder(orderId, market.symbol);
this.removeOrder(market.kind, market.id, String(orderId));
} catch (error) {
errors.push({ orderId, error });
}
})
);
if (errors.length) {
const detail = errors.map((entry) => `${entry.orderId}: ${extractMessage(entry.error)}`).join("; ");
throw new Error(`Binance cancelOrders failed: ${detail}`);
}
}
async cancelAllOrders(params: { symbol: string }): Promise<void> {
await this.ensureInitialized(params.symbol);
const market = this.resolveMarket(params.symbol);
const exchange = this.getExchange(market.kind);
try {
if (typeof exchange.cancelAllOrders === "function") {
await exchange.cancelAllOrders(market.symbol);
} else {
const openOrders = (await exchange.fetchOpenOrders(market.symbol)) as CcxtOrder[];
await Promise.all(openOrders.map((order) => exchange.cancelOrder(order.id, market.symbol)));
}
this.removeOrdersByMarket(market.kind, market.id);
} catch (error) {
throw new Error(`Binance cancelAllOrders failed: ${extractMessage(error)}`);
}
}
async getPrecision(symbol?: string): Promise<ExchangePrecision | null> {
await this.ensureInitialized(symbol ?? this.defaultSymbol);
const market = this.resolveMarket(symbol ?? this.defaultSymbol);
const exchange = this.getExchange(market.kind);
const marketInfo = exchange.markets?.[market.symbol] ?? null;
if (!marketInfo) return null;
const filters = Array.isArray(marketInfo?.info?.filters) ? marketInfo.info.filters : [];
const priceFilter = filters.find((f: any) => String(f?.filterType ?? "").toUpperCase() === "PRICE_FILTER");
const lotFilter = filters.find((f: any) => String(f?.filterType ?? "").toUpperCase() === "LOT_SIZE");
const notionalFilter = filters.find((f: any) =>
["MIN_NOTIONAL", "NOTIONAL"].includes(String(f?.filterType ?? "").toUpperCase())
);
const tickSize = Number(priceFilter?.tickSize ?? 0);
const qtyStep = Number(lotFilter?.stepSize ?? 0);
const minBase = Number(lotFilter?.minQty ?? 0);
const minQuote = Number(notionalFilter?.minNotional ?? 0);
const priceTick = Number.isFinite(tickSize) && tickSize > 0
? tickSize
: Math.pow(10, -(Number(marketInfo.precision?.price ?? 1)));
const step = Number.isFinite(qtyStep) && qtyStep > 0
? qtyStep
: Math.pow(10, -(Number(marketInfo.precision?.amount ?? 1)));
return {
priceTick,
qtyStep: step,
priceDecimals: Number.isFinite(Number(marketInfo.precision?.price)) ? Number(marketInfo.precision.price) : undefined,
sizeDecimals: Number.isFinite(Number(marketInfo.precision?.amount)) ? Number(marketInfo.precision.amount) : undefined,
minBaseAmount: Number.isFinite(minBase) && minBase > 0 ? minBase : undefined,
minQuoteAmount: Number.isFinite(minQuote) && minQuote > 0 ? minQuote : undefined,
};
}
async queryOpenOrders(): Promise<Order[]> {
await this.ensureInitialized(this.defaultSymbol);
const kinds = this.getPrivateKinds();
const result: Order[] = [];
for (const kind of kinds) {
const exchange = this.getExchange(kind);
const openOrders = (await exchange.fetchOpenOrders()) as CcxtOrder[];
for (const order of openOrders) {
const market = this.resolveMarketByCcxtSymbol(kind, String(order.symbol ?? ""));
const mapped = this.mapCcxtOrder(order, kind, market?.id ?? String(order.symbol ?? ""));
if (this.isOrderActive(mapped)) {
result.push(mapped);
}
}
}
return result;
}
async queryAccountSnapshot(): Promise<AccountSnapshot | null> {
await this.ensureInitialized(this.defaultSymbol);
const kinds = this.getPrivateKinds();
for (const kind of kinds) {
await this.fetchAndUpdateAccount(kind);
}
return this.buildCombinedAccountSnapshot();
}
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
await this.ensureInitialized(symbol);
const market = this.resolveMarket(symbol, "perp");
const exchange = this.getExchange("perp");
if (typeof exchange.setMarginMode !== "function") {
throw new Error("Binance setMarginMode is not supported by current ccxt build");
}
await exchange.setMarginMode(marginMode, market.symbol);
}
async forceCancelAllOrders(): Promise<boolean> {
await this.ensureInitialized(this.defaultSymbol);
const market = this.defaultMarket ?? this.resolveMarket(this.defaultSymbol);
await this.cancelAllOrders({ symbol: this.resolveDisplaySymbol(market.kind, market.id) });
const open = await this.queryOpenOrders();
return open.length === 0;
}
private activateKind(kind: MarketKind): void {
if (!this.activeKinds.has(kind)) {
this.activeKinds.add(kind);
}
if (this.accountListeners.size || this.orderListeners.size) {
this.ensureUserStream(kind);
}
}
private ensurePrivateStreaming(): void {
for (const kind of this.getPrivateKinds()) {
this.ensureUserStream(kind);
}
}
private ensureUserStream(kind: MarketKind): void {
const state = this.userStreams[kind];
if (state.active) return;
state.active = true;
state.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
void this.bootstrapUserStream(state);
}
private stopUserStream(kind: MarketKind): void {
const state = this.userStreams[kind];
state.active = false;
if (state.keepaliveTimer) {
clearInterval(state.keepaliveTimer);
state.keepaliveTimer = null;
}
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
if (state.ws) {
try {
state.ws.close();
} catch {
// ignore
}
state.ws = null;
}
state.listenKey = null;
}
private async bootstrapUserStream(state: UserStreamState): Promise<void> {
if (!state.active || this.destroyed) return;
try {
const listenKey = await this.createListenKey(state.kind);
state.listenKey = listenKey;
this.startListenKeyKeepAlive(state);
this.connectUserSocket(state);
} catch (error) {
this.logger(`userStream:${state.kind}:bootstrap`, error);
this.scheduleUserReconnect(state);
}
}
private connectUserSocket(state: UserStreamState): void {
if (!state.active || !state.listenKey || this.destroyed) return;
if (state.ws && (state.ws.readyState === WebSocketCtor.OPEN || state.ws.readyState === WebSocketCtor.CONNECTING)) {
return;
}
const url = this.buildWsUrl(state.kind, state.listenKey);
const ws = new WebSocketCtor(url);
state.ws = ws;
const handleOpen = () => {
state.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
};
const handleClose = () => {
if (state.ws === ws) {
state.ws = null;
}
if (!state.active || this.destroyed) return;
this.scheduleUserReconnect(state);
};
const handleError = (error: unknown) => {
this.logger(`userStream:${state.kind}:error`, error);
};
const handleMessage = (payload: unknown) => {
this.handleUserPayload(state.kind, payload);
};
this.bindWsListeners(ws, handleOpen, handleMessage, handleClose, handleError);
}
private scheduleUserReconnect(state: UserStreamState): void {
if (!state.active || this.destroyed) return;
if (state.reconnectTimer) return;
state.reconnectTimer = setTimeout(() => {
state.reconnectTimer = null;
state.reconnectDelayMs = Math.min(state.reconnectDelayMs * 2, RECONNECT_MAX_DELAY_MS);
void this.bootstrapUserStream(state);
}, state.reconnectDelayMs);
}
private startListenKeyKeepAlive(state: UserStreamState): void {
if (state.keepaliveTimer) {
clearInterval(state.keepaliveTimer);
}
state.keepaliveTimer = setInterval(() => {
void this.keepAliveListenKey(state);
}, LISTEN_KEY_KEEPALIVE_MS);
}
private async keepAliveListenKey(state: UserStreamState): Promise<void> {
if (!state.active || !state.listenKey) return;
try {
await this.callListenKeyEndpoint(state.kind, "PUT", state.listenKey);
} catch (error) {
this.logger(`listenKeyKeepalive:${state.kind}`, error);
}
}
private async createListenKey(kind: MarketKind): Promise<string> {
const data = await this.callListenKeyEndpoint(kind, "POST");
const listenKey = String(data?.listenKey ?? "");
if (!listenKey) {
throw new Error(`Binance ${kind} listenKey is missing`);
}
return listenKey;
}
private async callListenKeyEndpoint(kind: MarketKind, method: "POST" | "PUT", listenKey?: string): Promise<any> {
const endpoint = kind === "spot" ? "/api/v3/userDataStream" : "/fapi/v1/listenKey";
const base = kind === "spot" ? this.spotRestUrl : this.futuresRestUrl;
const url = `${base}${endpoint}`;
const payload = new URLSearchParams();
if (listenKey) payload.set("listenKey", listenKey);
const response = await axios.request({
method,
url,
headers: { "X-MBX-APIKEY": this.apiKey },
data: payload.toString(),
timeout: 15_000,
});
return response.data;
}
private handleUserPayload(kind: MarketKind, rawPayload: unknown): void {
const decoded = decodeWsData(rawPayload);
const payload = decoded && typeof decoded === "object" && "data" in (decoded as any)
? (decoded as any).data
: decoded;
if (!payload || typeof payload !== "object") return;
const eventType = String((payload as any).e ?? "").toUpperCase();
if (!eventType) return;
if (kind === "spot") {
if (eventType === "EXECUTIONREPORT") {
const order = this.mapSpotExecutionReport(payload);
if (order) {
this.upsertOrder(order, "spot", order.symbol);
}
return;
}
if (eventType === "OUTBOUNDACCOUNTPOSITION") {
this.applySpotAccountPositionEvent(payload);
this.emitAccount();
return;
}
if (eventType === "BALANCEUPDATE") {
this.applySpotBalanceUpdateEvent(payload);
this.emitAccount();
return;
}
return;
}
if (eventType === "ORDER_TRADE_UPDATE") {
const order = this.mapPerpOrderTradeUpdate(payload);
if (order) {
this.upsertOrder(order, "perp", order.symbol);
}
return;
}
if (eventType === "ACCOUNT_UPDATE") {
this.applyPerpAccountUpdateEvent(payload);
this.emitAccount();
return;
}
}
private applySpotAccountPositionEvent(payload: any): void {
const balances = Array.isArray(payload?.B) ? payload.B : [];
for (const entry of balances) {
const asset = String(entry?.a ?? "").toUpperCase();
if (!asset) continue;
const free = toNumber(entry?.f);
const locked = toNumber(entry?.l);
if (Math.abs(free) < 1e-12 && Math.abs(locked) < 1e-12) {
this.spotBalances.delete(asset);
} else {
this.spotBalances.set(asset, { free, locked });
}
}
this.lastSpotSnapshot = this.buildSpotSnapshot();
}
private applySpotBalanceUpdateEvent(payload: any): void {
const asset = String(payload?.a ?? "").toUpperCase();
if (!asset) return;
const delta = toNumber(payload?.d);
const prev = this.spotBalances.get(asset) ?? { free: 0, locked: 0 };
const next = { ...prev, free: prev.free + delta };
if (Math.abs(next.free) < 1e-12 && Math.abs(next.locked) < 1e-12) {
this.spotBalances.delete(asset);
} else {
this.spotBalances.set(asset, next);
}
this.lastSpotSnapshot = this.buildSpotSnapshot();
}
private applyPerpAccountUpdateEvent(payload: any): void {
const account = payload?.a;
const balances = Array.isArray(account?.B) ? account.B : [];
const positions = Array.isArray(account?.P) ? account.P : [];
const updateTime = Number(payload?.E ?? payload?.T ?? Date.now());
for (const entry of balances) {
const asset = String(entry?.a ?? "").toUpperCase();
if (!asset) continue;
const wallet = toNumber(entry?.wb);
const available = toNumber(entry?.cw ?? entry?.wb);
if (Math.abs(wallet) < 1e-12 && Math.abs(available) < 1e-12) {
this.perpBalances.delete(asset);
} else {
this.perpBalances.set(asset, { wallet, available });
}
}
for (const position of positions) {
const symbolRaw = String(position?.s ?? "").toUpperCase();
if (!symbolRaw) continue;
const amount = toNumber(position?.pa);
const symbol = this.resolveDisplaySymbol("perp", symbolRaw);
const key = symbol;
if (Math.abs(amount) < 1e-12) {
this.perpPositions.delete(key);
continue;
}
const mark = this.lastMarkPriceBySymbol.get(symbolRaw);
this.perpPositions.set(key, {
symbol,
positionAmt: String(amount),
entryPrice: String(toNumber(position?.ep)),
unrealizedProfit: String(toNumber(position?.up)),
positionSide: normalizePositionSide(position?.ps),
updateTime: Number.isFinite(updateTime) ? updateTime : Date.now(),
marginType: position?.mt != null ? String(position.mt) : undefined,
isolatedMargin: position?.iw != null ? String(position.iw) : undefined,
markPrice: mark != null ? String(mark) : undefined,
});
}
this.lastPerpSnapshot = this.buildPerpSnapshot();
}
private mapSpotExecutionReport(payload: any): Order | null {
const order = payload;
const symbolRaw = String(order?.s ?? "").toUpperCase();
if (!symbolRaw) return null;
const symbol = this.resolveDisplaySymbol("spot", symbolRaw);
const executedQty = toNumber(order?.z);
const cumQuote = toNumber(order?.Z);
const avgPrice = executedQty > 0 ? cumQuote / executedQty : 0;
return {
orderId: String(order?.i ?? ""),
clientOrderId: String(order?.c ?? ""),
symbol,
side: String(order?.S ?? "BUY").toUpperCase() === "SELL" ? "SELL" : "BUY",
type: this.mapBinanceOrderTypeToAster(order?.o, "spot"),
status: String(order?.X ?? ""),
price: String(order?.p ?? "0"),
origQty: String(order?.q ?? "0"),
executedQty: String(order?.z ?? "0"),
stopPrice: String(order?.P ?? "0"),
time: Number(order?.O ?? order?.T ?? Date.now()),
updateTime: Number(order?.E ?? order?.T ?? Date.now()),
reduceOnly: false,
closePosition: false,
avgPrice: Number.isFinite(avgPrice) ? String(avgPrice) : undefined,
cumQuote: String(order?.Z ?? "0"),
timeInForce: normalizeTimeInForce(order?.f),
};
}
private mapPerpOrderTradeUpdate(payload: any): Order | null {
const order = payload?.o;
if (!order) return null;
const symbolRaw = String(order?.s ?? "").toUpperCase();
if (!symbolRaw) return null;
const symbol = this.resolveDisplaySymbol("perp", symbolRaw);
return {
orderId: String(order?.i ?? ""),
clientOrderId: String(order?.c ?? ""),
symbol,
side: String(order?.S ?? "BUY").toUpperCase() === "SELL" ? "SELL" : "BUY",
type: this.mapBinanceOrderTypeToAster(order?.o, "perp"),
status: String(order?.X ?? ""),
price: String(order?.p ?? "0"),
origQty: String(order?.q ?? "0"),
executedQty: String(order?.z ?? "0"),
stopPrice: String(order?.sp ?? "0"),
time: Number(order?.T ?? payload?.E ?? Date.now()),
updateTime: Number(payload?.E ?? order?.T ?? Date.now()),
reduceOnly: Boolean(order?.R),
closePosition: Boolean(order?.cp),
workingType: order?.wt != null ? String(order.wt) : undefined,
activationPrice: order?.AP != null ? String(order.AP) : undefined,
avgPrice: order?.ap != null ? String(order.ap) : undefined,
cumQuote: order?.Z != null ? String(order.Z) : undefined,
positionSide: normalizePositionSide(order?.ps),
timeInForce: normalizeTimeInForce(order?.f),
};
}
private ensureSubscription<T>(
map: Map<string, PublicSubscription<T>>,
key: string,
kind: MarketKind,
stream: string,
transform: (payload: unknown) => T | null
): PublicSubscription<T> {
const existing = map.get(key);
if (existing) return existing;
const subscription: PublicSubscription<T> = {
kind,
stream,
listeners: new Set(),
transform,
ws: null,
reconnectTimer: null,
reconnectDelayMs: RECONNECT_BASE_DELAY_MS,
};
map.set(key, subscription);
this.connectSubscription(map, key, subscription);
return subscription;
}
private connectSubscription<T>(
map: Map<string, PublicSubscription<T>>,
key: string,
subscription: PublicSubscription<T>
): void {
if (this.destroyed) return;
const url = this.buildWsUrl(subscription.kind, subscription.stream);
const ws = new WebSocketCtor(url);
subscription.ws = ws;
const handleOpen = () => {
subscription.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
};
const handleClose = () => {
if (subscription.ws === ws) {
subscription.ws = null;
}
if (this.destroyed) return;
this.scheduleSubscriptionReconnect(map, key, subscription);
};
const handleError = (error: unknown) => {
this.logger(`publicWs:${key}`, error);
};
const handleMessage = (rawPayload: unknown) => {
const decoded = decodeWsData(rawPayload);
const payload = decoded && typeof decoded === "object" && "data" in (decoded as any)
? (decoded as any).data
: decoded;
if (!payload) return;
const mapped = subscription.transform(payload);
if (!mapped) return;
for (const listener of subscription.listeners) {
try {
listener(mapped);
} catch (error) {
this.logger(`publicListener:${key}`, error);
}
}
};
this.bindWsListeners(ws, handleOpen, handleMessage, handleClose, handleError);
}
private scheduleSubscriptionReconnect<T>(
map: Map<string, PublicSubscription<T>>,
key: string,
subscription: PublicSubscription<T>
): void {
if (this.destroyed) return;
if (subscription.reconnectTimer) return;
subscription.reconnectTimer = setTimeout(() => {
subscription.reconnectTimer = null;
subscription.reconnectDelayMs = Math.min(subscription.reconnectDelayMs * 2, RECONNECT_MAX_DELAY_MS);
this.connectSubscription(map, key, subscription);
}, subscription.reconnectDelayMs);
}
private closeSubscription<T>(subscription: PublicSubscription<T>): void {
if (subscription.reconnectTimer) {
clearTimeout(subscription.reconnectTimer);
subscription.reconnectTimer = null;
}
if (subscription.ws) {
try {
subscription.ws.close();
} catch {
// ignore
}
subscription.ws = null;
}
}
private bindWsListeners(
ws: WebSocket,
onOpen: () => void,
onMessage: (data: unknown) => void,
onClose: () => void,
onError: (error: unknown) => void
): void {
const handlePing = (data: unknown) => {
if (ws && "pong" in ws && typeof (ws as any).pong === "function") {
try {
(ws as any).pong(data as any);
} catch (error) {
this.logger("wsPong", error);
}
}
};
if ("addEventListener" in ws && typeof ws.addEventListener === "function") {
ws.addEventListener("open", onOpen as any);
ws.addEventListener("message", ((event: MessageEvent) => onMessage(event.data)) as any);
ws.addEventListener("close", onClose as any);
ws.addEventListener("error", ((event: Event) => onError((event as any)?.error ?? event)) as any);
ws.addEventListener("ping", (handlePing as any));
return;
}
if ("on" in ws && typeof (ws as any).on === "function") {
const socket = ws as any;
socket.on("open", onOpen);
socket.on("message", onMessage);
socket.on("close", onClose);
socket.on("error", onError);
socket.on("ping", handlePing);
return;
}
(ws as any).onopen = onOpen;
(ws as any).onmessage = (event: any) => onMessage(event?.data);
(ws as any).onclose = onClose;
(ws as any).onerror = (event: any) => onError(event?.error ?? event);
}
private mapDepthPayload(payload: unknown, market: BinanceMarketRef): Depth | null {
const data = payload as any;
const bids = Array.isArray(data?.b) ? data.b : [];
const asks = Array.isArray(data?.a) ? data.a : [];
if (!bids.length || !asks.length) return null;
return {
lastUpdateId: Number(data?.u ?? data?.lastUpdateId ?? Date.now()),
bids: bids
.filter((entry: any) => Array.isArray(entry) && entry.length >= 2)
.map((entry: any) => [String(entry[0]), String(entry[1])]),
asks: asks
.filter((entry: any) => Array.isArray(entry) && entry.length >= 2)
.map((entry: any) => [String(entry[0]), String(entry[1])]),
eventTime: Number(data?.E ?? Date.now()),
symbol: this.resolveDisplaySymbol(market.kind, market.id),
};
}
private mapTickerPayload(payload: unknown, market: BinanceMarketRef): Ticker | null {
const data = payload as any;
const lastPrice = String(data?.c ?? "");
if (!lastPrice) return null;
const displaySymbol = this.resolveDisplaySymbol(market.kind, market.id);
const mark = toNumber(data?.w);
if (Number.isFinite(mark) && mark > 0) {
this.lastMarkPriceBySymbol.set(market.id, mark);
}
return {
symbol: displaySymbol,
lastPrice,
openPrice: String(data?.o ?? "0"),
highPrice: String(data?.h ?? "0"),
lowPrice: String(data?.l ?? "0"),
volume: String(data?.v ?? "0"),
quoteVolume: String(data?.q ?? "0"),
eventTime: Number(data?.E ?? Date.now()),
priceChange: data?.p != null ? String(data.p) : undefined,
priceChangePercent: data?.P != null ? String(data.P) : undefined,
weightedAvgPrice: data?.w != null ? String(data.w) : undefined,
bidPrice: data?.b != null ? String(data.b) : undefined,
askPrice: data?.a != null ? String(data.a) : undefined,
markPrice: data?.w != null ? String(data.w) : undefined,
};
}
private mapKlinePayload(payload: unknown, interval: string): Kline[] | null {
const data = payload as any;
const kline = data?.k;
if (!kline) return null;
return [
{
eventType: String(data?.e ?? "kline"),
eventTime: Number(data?.E ?? Date.now()),
symbol: String(data?.s ?? ""),
interval,
openTime: Number(kline?.t ?? Date.now()),
closeTime: Number(kline?.T ?? Date.now()),
open: String(kline?.o ?? "0"),
close: String(kline?.c ?? "0"),
high: String(kline?.h ?? "0"),
low: String(kline?.l ?? "0"),
volume: String(kline?.v ?? "0"),
numberOfTrades: Number(kline?.n ?? 0),
quoteAssetVolume: kline?.q != null ? String(kline.q) : undefined,
takerBuyBaseAssetVolume: kline?.V != null ? String(kline.V) : undefined,
takerBuyQuoteAssetVolume: kline?.Q != null ? String(kline.Q) : undefined,
isClosed: Boolean(kline?.x),
},
];
}
private mapFundingPayload(
payload: unknown,
market: BinanceMarketRef
): { symbol: string; fundingRate: number; updateTime: number } | null {
const data = payload as any;
const rate = Number(data?.r);
if (!Number.isFinite(rate)) return null;
const markPrice = Number(data?.p);
if (Number.isFinite(markPrice) && markPrice > 0) {
this.lastMarkPriceBySymbol.set(market.id, markPrice);
}
return {
symbol: this.resolveDisplaySymbol(market.kind, market.id),
fundingRate: rate,
updateTime: Number(data?.E ?? Date.now()),
};
}
private startAccountPolling(): void {
if (this.accountPollTimer) return;
const poll = async () => {
if (!this.accountListeners.size) return;
try {
for (const kind of this.getPrivateKinds()) {
await this.fetchAndUpdateAccount(kind);
}
this.emitAccount();
} catch (error) {
this.logger("accountPoll", error);
}
};
void poll();
this.accountPollTimer = setInterval(() => {
void poll();
}, this.pollIntervals.account);
}
private startOrderPolling(): void {
if (this.orderPollTimer) return;
const poll = async () => {
if (!this.orderListeners.size) return;
try {
for (const kind of this.getPrivateKinds()) {
await this.fetchAndUpdateOrders(kind);
}
this.emitOrders();
} catch (error) {
this.logger("ordersPoll", error);
}
};
void poll();
this.orderPollTimer = setInterval(() => {
void poll();
}, this.pollIntervals.orders);
}
private stopPolling(): void {
if (this.accountPollTimer) {
clearInterval(this.accountPollTimer);
this.accountPollTimer = null;
}
if (this.orderPollTimer) {
clearInterval(this.orderPollTimer);
this.orderPollTimer = null;
}
}
private async fetchAndUpdateAccount(kind: MarketKind): Promise<void> {
if (kind === "spot") {
const balance = (await this.spotExchange.fetchBalance()) as Balances;
this.applySpotBalanceSnapshot(balance);
this.lastSpotSnapshot = this.buildSpotSnapshot();
return;
}
const balance = (await this.perpExchange.fetchBalance()) as Balances;
this.applyPerpBalanceSnapshot(balance);
await this.attachPerpPositions();
this.lastPerpSnapshot = this.buildPerpSnapshot();
}
private applySpotBalanceSnapshot(balance: Balances): void {
const freeMap = (balance.free ?? {}) as Record<string, number>;
const usedMap = (balance.used ?? {}) as Record<string, number>;
const keys = new Set<string>([...Object.keys(freeMap), ...Object.keys(usedMap)]);
this.spotBalances.clear();
for (const asset of keys) {
const free = toNumber(freeMap[asset]);
const locked = toNumber(usedMap[asset]);
if (Math.abs(free) < 1e-12 && Math.abs(locked) < 1e-12) continue;
this.spotBalances.set(asset.toUpperCase(), { free, locked });
}
}
private applyPerpBalanceSnapshot(balance: Balances): void {
const total = (balance.total ?? {}) as Record<string, number>;
const free = (balance.free ?? {}) as Record<string, number>;
this.perpBalances.clear();
for (const asset of Object.keys(total)) {
const wallet = toNumber(total[asset]);
const available = toNumber(free[asset] ?? wallet);
if (Math.abs(wallet) < 1e-12 && Math.abs(available) < 1e-12) continue;
this.perpBalances.set(asset.toUpperCase(), { wallet, available });
}
}
private async attachPerpPositions(): Promise<void> {
const next = new Map<string, AccountPosition>();
try {
const raw = (await this.perpExchange.fetchPositions()) as any[];
for (const row of raw ?? []) {
const amount = toNumber(row?.contracts ?? row?.positionAmt ?? row?.info?.positionAmt);
if (Math.abs(amount) < 1e-12) continue;
const id = String(row?.id ?? row?.symbol ?? row?.info?.symbol ?? "").toUpperCase();
if (!id) continue;
const market = this.resolveMarketByCcxtSymbol("perp", String(row?.symbol ?? ""));
const symbol = this.resolveDisplaySymbol("perp", market?.id ?? id);
const markFromTicker = this.lastMarkPriceBySymbol.get(market?.id ?? id);
next.set(symbol, {
symbol,
positionAmt: String(amount),
entryPrice: String(toNumber(row?.entryPrice ?? row?.info?.entryPrice)),
unrealizedProfit: String(toNumber(row?.unrealizedPnl ?? row?.info?.unrealizedProfit)),
positionSide: normalizePositionSide(row?.side ?? row?.info?.positionSide),
updateTime: Date.now(),
markPrice: Number.isFinite(toNumber(row?.markPrice))
? String(toNumber(row?.markPrice))
: markFromTicker != null
? String(markFromTicker)
: undefined,
});
}
} catch (error) {
this.logger("fetchPositions", error);
}
this.perpPositions.clear();
for (const [key, value] of next.entries()) {
this.perpPositions.set(key, value);
}
}
private async fetchAndUpdateOrders(kind: MarketKind): Promise<void> {
const exchange = this.getExchange(kind);
const openOrders = (await exchange.fetchOpenOrders()) as CcxtOrder[];
const remote = new Map<string, Order>();
for (const order of openOrders) {
const market = this.resolveMarketByCcxtSymbol(kind, String(order?.symbol ?? ""));
const mapped = this.mapCcxtOrder(order, kind, market?.id ?? String(order?.symbol ?? ""));
if (!this.isOrderActive(mapped)) continue;
remote.set(hashOrderKey(kind, mapped.symbol, String(mapped.orderId)), mapped);
}
for (const key of Array.from(this.localOrders.keys())) {
if (!key.startsWith(`${kind}:`)) continue;
if (!remote.has(key)) this.localOrders.delete(key);
}
for (const [key, value] of remote.entries()) {
this.localOrders.set(key, value);
}
}
private emitAccount(): void {
if (!this.accountListeners.size) return;
const snapshot = this.buildCombinedAccountSnapshot();
if (!snapshot) return;
for (const listener of this.accountListeners) {
try {
listener(snapshot);
} catch (error) {
this.logger("emitAccount", error);
}
}
}
private emitOrders(): void {
if (!this.orderListeners.size) return;
const orders = Array.from(this.localOrders.values()).filter((order) => this.isOrderActive(order));
for (const listener of this.orderListeners) {
try {
listener(orders);
} catch (error) {
this.logger("emitOrders", error);
}
}
}
private upsertOrder(order: Order, kind: MarketKind, marketId: string): void {
const id = String(order.orderId);
const symbol = this.resolveDisplaySymbol(kind, marketId);
const normalized = { ...order, symbol };
const key = hashOrderKey(kind, symbol, id);
if (!this.isOrderActive(normalized)) {
this.localOrders.delete(key);
this.emitOrders();
return;
}
this.localOrders.set(key, normalized);
this.emitOrders();
}
private removeOrder(kind: MarketKind, marketId: string, orderId: string): void {
const symbol = this.resolveDisplaySymbol(kind, marketId);
const key = hashOrderKey(kind, symbol, orderId);
if (this.localOrders.delete(key)) {
this.emitOrders();
}
}
private removeOrdersByMarket(kind: MarketKind, marketId: string): void {
const symbol = this.resolveDisplaySymbol(kind, marketId);
const prefix = `${kind}:`;
let changed = false;
for (const [key, order] of this.localOrders.entries()) {
if (!key.startsWith(prefix)) continue;
if (order.symbol !== symbol) continue;
this.localOrders.delete(key);
changed = true;
}
if (changed) this.emitOrders();
}
private isOrderActive(order: Order): boolean {
const status = String(order.status ?? "").toUpperCase();
if (!status) return true;
if (status === "FILLED" || status === "CANCELED" || status === "CANCELLED" || status === "REJECTED" || status === "EXPIRED") {
return false;
}
if (status.includes("CLOSE")) return false;
return true;
}
private buildCombinedAccountSnapshot(): AccountSnapshot | null {
const kinds = this.getPrivateKinds();
if (kinds.length === 0) return null;
if (kinds.length === 1) {
return kinds[0] === "spot" ? this.buildSpotSnapshot() : this.buildPerpSnapshot();
}
const spot = this.buildSpotSnapshot();
const perp = this.buildPerpSnapshot();
const perpAssetsTagged: AccountAsset[] = perp.assets.map((asset) => ({
...asset,
asset: `${asset.asset}0`,
}));
const assets = [...spot.assets, ...perpAssetsTagged];
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: Math.max(spot.updateTime, perp.updateTime),
totalWalletBalance: String(toNumber(spot.totalWalletBalance) + toNumber(perp.totalWalletBalance)),
totalUnrealizedProfit: perp.totalUnrealizedProfit,
positions: perp.positions,
assets,
marketType: "perp",
baseAsset: perp.baseAsset,
quoteAsset: perp.quoteAsset,
};
}
private buildSpotSnapshot(): AccountSnapshot {
const assets: AccountAsset[] = [];
let totalWallet = 0;
const now = Date.now();
for (const [asset, balance] of this.spotBalances.entries()) {
const wallet = balance.free + balance.locked;
totalWallet += wallet;
assets.push({
asset,
walletBalance: String(wallet),
availableBalance: String(balance.free),
updateTime: now,
});
}
const market = this.defaultKind === "spot" ? this.defaultMarket : null;
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: now,
totalWalletBalance: String(totalWallet),
totalUnrealizedProfit: "0",
positions: [],
assets,
marketType: "spot",
baseAsset: market?.base,
quoteAsset: market?.quote,
};
}
private buildPerpSnapshot(): AccountSnapshot {
const now = Date.now();
const assets: AccountAsset[] = [];
let totalWallet = 0;
for (const [asset, balance] of this.perpBalances.entries()) {
totalWallet += balance.wallet;
assets.push({
asset,
walletBalance: String(balance.wallet),
availableBalance: String(balance.available),
updateTime: now,
});
}
const positions = Array.from(this.perpPositions.values());
let totalUnrealized = 0;
for (const position of positions) {
totalUnrealized += toNumber(position.unrealizedProfit);
}
const market = this.defaultKind === "perp" ? this.defaultMarket : null;
return {
canTrade: true,
canDeposit: true,
canWithdraw: true,
updateTime: now,
totalWalletBalance: String(totalWallet),
totalUnrealizedProfit: String(totalUnrealized),
positions,
assets,
marketType: "perp",
baseAsset: market?.base,
quoteAsset: market?.quote,
};
}
private mapCcxtOrder(order: CcxtOrder, kind: MarketKind, marketId: string): Order {
const symbol = this.resolveDisplaySymbol(kind, marketId);
const side = String(order.side ?? "buy").toUpperCase() === "SELL" ? "SELL" : "BUY";
return {
orderId: String(order.id ?? ""),
clientOrderId: String(order.clientOrderId ?? ""),
symbol,
side,
type: this.mapCcxtOrderTypeToAster(order.type, kind),
status: String(order.status ?? ""),
price: String(order.price ?? 0),
origQty: String(order.amount ?? 0),
executedQty: String(order.filled ?? 0),
stopPrice: String(order.stopPrice ?? 0),
time: Number(order.timestamp ?? Date.now()),
updateTime: Number(order.lastUpdateTimestamp ?? order.timestamp ?? Date.now()),
reduceOnly: Boolean((order.info as any)?.reduceOnly ?? false),
closePosition: Boolean((order.info as any)?.closePosition ?? false),
avgPrice: order.average != null ? String(order.average) : undefined,
cumQuote: order.cost != null ? String(order.cost) : undefined,
timeInForce: normalizeTimeInForce(order.timeInForce),
positionSide: normalizePositionSide((order.info as any)?.positionSide),
};
}
private mapOrderTypeToCcxt(type: OrderType, kind: MarketKind): string {
if (kind === "spot") {
if (type === "STOP_MARKET") return "stop_loss";
if (type === "TAKE_PROFIT_MARKET") return "take_profit";
return type.toLowerCase();
}
const map: Record<OrderType, string> = {
LIMIT: "limit",
MARKET: "market",
STOP: "stop",
STOP_MARKET: "stop_market",
TAKE_PROFIT: "take_profit",
TAKE_PROFIT_MARKET: "take_profit_market",
TRAILING_STOP_MARKET: "trailing_stop_market",
};
return map[type] ?? "limit";
}
private mapCcxtOrderTypeToAster(type: string | undefined, kind: MarketKind): OrderType {
const normalized = String(type ?? "").toLowerCase();
if (kind === "spot") {
if (normalized.includes("stop")) return "STOP_MARKET";
if (normalized.includes("take_profit")) return "TAKE_PROFIT_MARKET";
if (normalized.includes("market")) return "MARKET";
return "LIMIT";
}
if (normalized === "market") return "MARKET";
if (normalized === "stop" || normalized === "stop_market") return "STOP_MARKET";
if (normalized === "trailing_stop_market" || normalized === "trailing-stop") return "TRAILING_STOP_MARKET";
if (normalized === "take_profit_market") return "TAKE_PROFIT_MARKET";
if (normalized === "take_profit") return "TAKE_PROFIT";
return "LIMIT";
}
private mapBinanceOrderTypeToAster(type: unknown, kind: MarketKind): OrderType {
const upper = String(type ?? "").toUpperCase();
if (upper === "MARKET") return "MARKET";
if (upper === "TRAILING_STOP_MARKET") return "TRAILING_STOP_MARKET";
if (upper.includes("STOP")) return "STOP_MARKET";
if (upper.includes("TAKE_PROFIT")) return "TAKE_PROFIT_MARKET";
if (kind === "spot" && upper === "STOP_LOSS_LIMIT") return "STOP_MARKET";
return "LIMIT";
}
private getExchange(kind: MarketKind): any {
return kind === "spot" ? this.spotExchange : this.perpExchange;
}
private getPrivateKinds(): MarketKind[] {
if (this.activeKinds.size > 0) {
return Array.from(this.activeKinds.values());
}
return [this.defaultKind];
}
private rebuildMarketIndex(kind: MarketKind): void {
const index = this.marketIndex[kind];
index.clear();
const exchange = this.getExchange(kind);
const markets = exchange.markets ?? {};
for (const market of Object.values(markets) as any[]) {
const symbol = String(market?.symbol ?? "").trim();
if (!symbol) continue;
const id = String(market?.id ?? symbol).trim().toUpperCase();
const ref: BinanceMarketRef = {
kind,
symbol,
id,
base: market?.base != null ? String(market.base).toUpperCase() : undefined,
quote: market?.quote != null ? String(market.quote).toUpperCase() : undefined,
};
const keys = new Set<string>([
symbol.toUpperCase(),
id,
normalizeSymbolKey(symbol),
normalizeSymbolKey(id),
]);
if (ref.base && ref.quote) {
keys.add(`${ref.base}${ref.quote}`);
}
for (const key of keys) {
if (!key) continue;
if (!index.has(key)) index.set(key, ref);
}
}
}
private resolveMarket(symbol: string, preferredKind?: MarketKind): BinanceMarketRef {
const parsed = parseSymbolHint(symbol);
const normalized = parsed.normalized;
if (!normalized) {
throw new Error("Binance symbol is required");
}
const keys = [normalized.toUpperCase(), normalizeSymbolKey(normalized)];
const tryResolveIn = (kind: MarketKind): BinanceMarketRef | null => {
const index = this.marketIndex[kind];
for (const key of keys) {
const found = index.get(key);
if (found) return found;
}
return null;
};
const forced = parsed.forcedKind;
if (forced) {
const target = tryResolveIn(forced);
if (!target) {
throw new Error(`Binance symbol ${symbol} is not available on ${forced}`);
}
this.registerAlias(forced, target.id, symbol);
return target;
}
if (preferredKind) {
const target = tryResolveIn(preferredKind);
if (target) {
this.registerAlias(preferredKind, target.id, symbol);
return target;
}
}
const spot = tryResolveIn("spot");
const perp = tryResolveIn("perp");
if (spot && !perp) {
this.registerAlias("spot", spot.id, symbol);
return spot;
}
if (perp && !spot) {
this.registerAlias("perp", perp.id, symbol);
return perp;
}
if (spot && perp) {
const target = this.defaultKind === "spot" ? spot : perp;
this.registerAlias(target.kind, target.id, symbol);
return target;
}
throw new Error(`Binance symbol not found: ${symbol}`);
}
private resolveMarketByCcxtSymbol(kind: MarketKind, ccxtSymbol: string): BinanceMarketRef | null {
const index = this.marketIndex[kind];
const keyA = ccxtSymbol.toUpperCase();
const keyB = normalizeSymbolKey(ccxtSymbol);
return index.get(keyA) ?? index.get(keyB) ?? null;
}
private registerAlias(kind: MarketKind, marketId: string, requestedSymbol: string): void {
const normalized = (requestedSymbol ?? "").trim().toUpperCase();
if (!normalized) return;
this.symbolAliasByKey.set(`${kind}:${marketId.toUpperCase()}`, normalized);
}
private resolveDisplaySymbol(kind: MarketKind, marketId: string): string {
const key = `${kind}:${marketId.toUpperCase()}`;
return this.symbolAliasByKey.get(key) ?? marketId.toUpperCase();
}
private buildWsUrl(kind: MarketKind, stream: string): string {
const base = kind === "spot" ? this.spotWsUrl : this.futuresWsUrl;
if (base.endsWith("/ws")) return `${base}/${stream}`;
if (base.includes("/stream")) return `${base}?streams=${encodeURIComponent(stream)}`;
return `${base}/ws/${stream}`;
}
}