@imbingox/acex 0.3.0-beta.6 → 0.3.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.3.0-beta.6",
3
+ "version": "0.3.1-beta.0",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,4 @@
1
+ import { SubscriptionMultiplexer } from "../../internal/subscription-multiplexer.ts";
1
2
  import type {
2
3
  MarketDefinition,
3
4
  VenueMarketCapabilities,
@@ -10,12 +11,34 @@ import type {
10
11
  MarketAdapter,
11
12
  StreamHandle,
12
13
  } from "../types.ts";
13
- import { subscribeBinanceBookTicker } from "./book-ticker.ts";
14
- import { subscribeBinanceMarkPrice } from "./mark-price.ts";
15
14
  import {
16
15
  type BinanceMarketDefinition,
17
16
  loadBinanceMarkets,
18
17
  } from "./market-catalog.ts";
18
+ import {
19
+ type BinanceStreamDescriptor,
20
+ type BinanceStreamMessage,
21
+ type BinanceStreamPayload,
22
+ BinanceStreamProtocol,
23
+ } from "./stream-protocol.ts";
24
+
25
+ const BINANCE_CONTROL_FRAME_MAX_PER_SEC = 5;
26
+ // Binance allows up to 1024 streams per connection; keep a conservative pool cap.
27
+ const BINANCE_MAX_SUBSCRIPTIONS_PER_CONNECTION = 200;
28
+
29
+ type BinanceMarketMultiplexer = SubscriptionMultiplexer<
30
+ BinanceStreamMessage,
31
+ BinanceStreamDescriptor,
32
+ BinanceStreamPayload
33
+ >;
34
+
35
+ interface BinanceMultiplexerConfig {
36
+ readonly initialMessageTimeoutMs: number;
37
+ readonly staleAfterMs: number;
38
+ readonly reconnectDelayMs: number;
39
+ readonly reconnectMaxDelayMs: number;
40
+ readonly now?: () => number;
41
+ }
19
42
 
20
43
  export class BinanceMarketAdapter implements MarketAdapter {
21
44
  readonly venue = "binance" as const;
@@ -27,6 +50,8 @@ export class BinanceMarketAdapter implements MarketAdapter {
27
50
  };
28
51
 
29
52
  private readonly definitions = new Map<string, BinanceMarketDefinition>();
53
+ private multiplexer: BinanceMarketMultiplexer | undefined;
54
+ private multiplexerConfig: BinanceMultiplexerConfig | undefined;
30
55
 
31
56
  async loadMarkets(): Promise<MarketDefinition[]> {
32
57
  const markets = await loadBinanceMarkets();
@@ -49,18 +74,38 @@ export class BinanceMarketAdapter implements MarketAdapter {
49
74
  throw new Error(`Unknown Binance market: ${market.symbol}`);
50
75
  }
51
76
 
52
- return subscribeBinanceBookTicker(
53
- binanceMarket,
77
+ const handle = this.getMultiplexer(options).subscribe(
78
+ {
79
+ channel: "l1book",
80
+ market: binanceMarket,
81
+ },
54
82
  {
55
- onBookTicker(update) {
56
- callbacks.onUpdate(update);
83
+ onPayload(payload, receivedAt) {
84
+ if (payload.channel !== "l1book") {
85
+ return;
86
+ }
87
+
88
+ callbacks.onUpdate({
89
+ bidPrice: payload.bidPrice,
90
+ bidSize: payload.bidSize,
91
+ askPrice: payload.askPrice,
92
+ askSize: payload.askSize,
93
+ exchangeTs: payload.exchangeTs,
94
+ receivedAt,
95
+ });
57
96
  },
58
97
  onFreshnessChange: callbacks.onFreshnessChange,
59
98
  onDisconnected: callbacks.onDisconnected,
60
99
  onError: callbacks.onError,
61
100
  },
62
- options,
63
101
  );
102
+
103
+ return {
104
+ ready: handle.ready,
105
+ close(): void {
106
+ handle.close();
107
+ },
108
+ };
64
109
  }
65
110
 
66
111
  createFundingRateStream(
@@ -73,17 +118,91 @@ export class BinanceMarketAdapter implements MarketAdapter {
73
118
  throw new Error(`Unknown Binance market: ${market.symbol}`);
74
119
  }
75
120
 
76
- return subscribeBinanceMarkPrice(
77
- binanceMarket,
121
+ const handle = this.getMultiplexer(options).subscribe(
78
122
  {
79
- onFundingRate(update) {
80
- callbacks.onUpdate(update);
123
+ channel: "fundingRate",
124
+ market: binanceMarket,
125
+ },
126
+ {
127
+ onPayload(payload, receivedAt) {
128
+ if (payload.channel !== "fundingRate") {
129
+ return;
130
+ }
131
+
132
+ callbacks.onUpdate({
133
+ fundingRate: payload.fundingRate,
134
+ nextFundingTime: payload.nextFundingTime,
135
+ markPrice: payload.markPrice,
136
+ indexPrice: payload.indexPrice,
137
+ exchangeTs: payload.exchangeTs,
138
+ receivedAt,
139
+ });
81
140
  },
82
141
  onFreshnessChange: callbacks.onFreshnessChange,
83
142
  onDisconnected: callbacks.onDisconnected,
84
143
  onError: callbacks.onError,
85
144
  },
86
- options,
87
145
  );
146
+
147
+ return {
148
+ ready: handle.ready,
149
+ close(): void {
150
+ handle.close();
151
+ },
152
+ };
153
+ }
154
+
155
+ private getMultiplexer(
156
+ options: L1BookStreamOptions | FundingRateStreamOptions,
157
+ ): BinanceMarketMultiplexer {
158
+ const config: BinanceMultiplexerConfig = {
159
+ initialMessageTimeoutMs: options.initialMessageTimeoutMs,
160
+ staleAfterMs: options.staleAfterMs,
161
+ reconnectDelayMs: options.reconnectDelayMs,
162
+ reconnectMaxDelayMs: options.reconnectMaxDelayMs,
163
+ now: options.now,
164
+ };
165
+
166
+ if (!this.multiplexer) {
167
+ this.multiplexer = new SubscriptionMultiplexer(
168
+ new BinanceStreamProtocol(),
169
+ {
170
+ initialMessageTimeoutMs: config.initialMessageTimeoutMs,
171
+ staleAfterMs: config.staleAfterMs,
172
+ reconnectDelayMs: config.reconnectDelayMs,
173
+ reconnectMaxDelayMs: config.reconnectMaxDelayMs,
174
+ controlFrameMaxPerSec: BINANCE_CONTROL_FRAME_MAX_PER_SEC,
175
+ maxSubscriptionsPerConnection:
176
+ BINANCE_MAX_SUBSCRIPTIONS_PER_CONNECTION,
177
+ now: config.now,
178
+ },
179
+ );
180
+ this.multiplexerConfig = config;
181
+ return this.multiplexer;
182
+ }
183
+
184
+ if (
185
+ !this.multiplexerConfig ||
186
+ !sameMultiplexerConfig(config, this.multiplexerConfig)
187
+ ) {
188
+ throw new Error(
189
+ "Binance market stream options differ from the active multiplexer; create a new adapter instance for different stream timing options",
190
+ );
191
+ }
192
+
193
+ return this.multiplexer;
88
194
  }
89
195
  }
196
+
197
+ function sameMultiplexerConfig(
198
+ left: BinanceMultiplexerConfig,
199
+ right: BinanceMultiplexerConfig,
200
+ ): boolean {
201
+ return (
202
+ left.initialMessageTimeoutMs === right.initialMessageTimeoutMs &&
203
+ left.staleAfterMs === right.staleAfterMs &&
204
+ left.reconnectDelayMs === right.reconnectDelayMs &&
205
+ left.reconnectMaxDelayMs === right.reconnectMaxDelayMs &&
206
+ left.now === right.now
207
+ );
208
+ }
@@ -0,0 +1,202 @@
1
+ import type { VenueStreamProtocol } from "../../internal/subscription-multiplexer.ts";
2
+ import type { BinanceMarketDefinition } from "./market-catalog.ts";
3
+
4
+ export type BinanceStreamChannel = "l1book" | "fundingRate";
5
+
6
+ export interface BinanceStreamDescriptor {
7
+ readonly channel: BinanceStreamChannel;
8
+ readonly market: BinanceMarketDefinition;
9
+ }
10
+
11
+ export interface BinanceStreamMessage {
12
+ readonly result?: unknown;
13
+ readonly id?: number | string;
14
+ readonly e?: string;
15
+ readonly E?: number;
16
+ readonly s?: string;
17
+ readonly b?: string;
18
+ readonly B?: string;
19
+ readonly a?: string;
20
+ readonly A?: string;
21
+ readonly p?: string;
22
+ readonly i?: string;
23
+ readonly r?: string;
24
+ readonly T?: number;
25
+ }
26
+
27
+ export type BinanceStreamPayload =
28
+ | {
29
+ readonly channel: "l1book";
30
+ readonly bidPrice: string;
31
+ readonly bidSize: string;
32
+ readonly askPrice: string;
33
+ readonly askSize: string;
34
+ readonly exchangeTs?: number;
35
+ }
36
+ | {
37
+ readonly channel: "fundingRate";
38
+ readonly fundingRate: string;
39
+ readonly nextFundingTime?: number;
40
+ readonly markPrice?: string;
41
+ readonly indexPrice?: string;
42
+ readonly exchangeTs?: number;
43
+ };
44
+
45
+ const BINANCE_SPOT_WS_BASE_URL = "wss://stream.binance.com:9443/ws";
46
+ const BINANCE_USDM_WS_BASE_URL = "wss://fstream.binance.com/ws";
47
+ const BINANCE_USDM_MARKET_WS_BASE_URL = "wss://fstream.binance.com/market/ws";
48
+ const BINANCE_COINM_WS_BASE_URL = "wss://dstream.binance.com/ws";
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+
54
+ function hasField(value: object, field: keyof BinanceStreamMessage): boolean {
55
+ return Object.hasOwn(value, field);
56
+ }
57
+
58
+ function streamName(descriptor: BinanceStreamDescriptor): string {
59
+ const channel = descriptor.channel === "l1book" ? "bookTicker" : "markPrice";
60
+ return `${descriptor.market.id.toLowerCase()}@${channel}`;
61
+ }
62
+
63
+ function connectionKeyFor(descriptor: BinanceStreamDescriptor): string {
64
+ switch (descriptor.market.family) {
65
+ case "spot":
66
+ if (descriptor.channel === "fundingRate") {
67
+ throw new Error(
68
+ `Funding rate is not supported for spot market: ${descriptor.market.symbol}`,
69
+ );
70
+ }
71
+ return BINANCE_SPOT_WS_BASE_URL;
72
+ case "usdm":
73
+ return descriptor.channel === "l1book"
74
+ ? BINANCE_USDM_WS_BASE_URL
75
+ : BINANCE_USDM_MARKET_WS_BASE_URL;
76
+ case "coinm":
77
+ return BINANCE_COINM_WS_BASE_URL;
78
+ }
79
+ }
80
+
81
+ export class BinanceStreamProtocol
82
+ implements
83
+ VenueStreamProtocol<
84
+ BinanceStreamMessage,
85
+ BinanceStreamDescriptor,
86
+ BinanceStreamPayload
87
+ >
88
+ {
89
+ private nextControlFrameId = 1;
90
+
91
+ subscriptionKey(descriptor: BinanceStreamDescriptor): string {
92
+ return `${descriptor.channel}:${descriptor.market.id}`;
93
+ }
94
+
95
+ connectionKey(descriptor: BinanceStreamDescriptor): string {
96
+ return connectionKeyFor(descriptor);
97
+ }
98
+
99
+ connectionUrl(connectionKey: string): string {
100
+ return connectionKey;
101
+ }
102
+
103
+ parseMessage(data: string): BinanceStreamMessage | undefined {
104
+ try {
105
+ const parsed = JSON.parse(data) as unknown;
106
+ if (!isRecord(parsed)) {
107
+ return undefined;
108
+ }
109
+
110
+ return parsed;
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ encodeSubscribe(descriptors: BinanceStreamDescriptor[]): string {
117
+ return this.encodeControlFrame("SUBSCRIBE", descriptors);
118
+ }
119
+
120
+ encodeUnsubscribe(descriptors: BinanceStreamDescriptor[]): string {
121
+ return this.encodeControlFrame("UNSUBSCRIBE", descriptors);
122
+ }
123
+
124
+ routeMessage(message: BinanceStreamMessage):
125
+ | {
126
+ kind: "data";
127
+ subscriptionKey: string;
128
+ payload: BinanceStreamPayload;
129
+ }
130
+ | { kind: "ack" }
131
+ | { kind: "ignore" } {
132
+ if (hasField(message, "result") || this.isIdOnlyAck(message)) {
133
+ return { kind: "ack" };
134
+ }
135
+
136
+ if (
137
+ typeof message.s === "string" &&
138
+ typeof message.r === "string" &&
139
+ message.e === "markPriceUpdate"
140
+ ) {
141
+ return {
142
+ kind: "data",
143
+ subscriptionKey: `fundingRate:${message.s}`,
144
+ payload: {
145
+ channel: "fundingRate",
146
+ fundingRate: message.r,
147
+ nextFundingTime: message.T,
148
+ markPrice: message.p,
149
+ indexPrice: message.i,
150
+ exchangeTs: message.E,
151
+ },
152
+ };
153
+ }
154
+
155
+ if (
156
+ typeof message.s === "string" &&
157
+ typeof message.b === "string" &&
158
+ typeof message.B === "string" &&
159
+ typeof message.a === "string" &&
160
+ typeof message.A === "string"
161
+ ) {
162
+ return {
163
+ kind: "data",
164
+ subscriptionKey: `l1book:${message.s}`,
165
+ payload: {
166
+ channel: "l1book",
167
+ bidPrice: message.b,
168
+ bidSize: message.B,
169
+ askPrice: message.a,
170
+ askSize: message.A,
171
+ exchangeTs: message.T,
172
+ },
173
+ };
174
+ }
175
+
176
+ return { kind: "ignore" };
177
+ }
178
+
179
+ private encodeControlFrame(
180
+ method: "SUBSCRIBE" | "UNSUBSCRIBE",
181
+ descriptors: BinanceStreamDescriptor[],
182
+ ): string {
183
+ const frame = {
184
+ method,
185
+ params: descriptors.map(streamName),
186
+ id: this.nextControlFrameId,
187
+ };
188
+ this.nextControlFrameId += 1;
189
+ return JSON.stringify(frame);
190
+ }
191
+
192
+ private isIdOnlyAck(message: BinanceStreamMessage): boolean {
193
+ return (
194
+ message.id !== undefined &&
195
+ message.e === undefined &&
196
+ message.s === undefined &&
197
+ message.b === undefined &&
198
+ message.a === undefined &&
199
+ message.r === undefined
200
+ );
201
+ }
202
+ }
@@ -115,7 +115,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
115
115
  privateAdapters.map((adapter) => [adapter.venue, adapter]),
116
116
  );
117
117
 
118
- this.marketManager = new MarketManagerImpl(this, marketAdapter, {
118
+ this.marketManager = new MarketManagerImpl(this, this.marketAdapters, {
119
119
  initialL1TimeoutMs: options.market?.l1InitialMessageTimeoutMs,
120
120
  l1StaleAfterMs: options.market?.l1StaleAfterMs,
121
121
  l1ReconnectDelayMs: options.market?.l1ReconnectDelayMs,
@@ -33,6 +33,7 @@ export interface ManagedWebSocketOptions<TMessage> {
33
33
 
34
34
  export interface ManagedWebSocketSession {
35
35
  readonly ready: Promise<void>;
36
+ send(data: string): void;
36
37
  close(): void;
37
38
  }
38
39
 
@@ -265,6 +266,13 @@ export function createManagedWebSocket<TMessage>(
265
266
 
266
267
  return {
267
268
  ready,
269
+ send(data: string): void {
270
+ if (!activeSocket || activeSocket.readyState !== WebSocket.OPEN) {
271
+ return;
272
+ }
273
+
274
+ activeSocket.send(data);
275
+ },
268
276
  close() {
269
277
  if (closed) {
270
278
  return;