@imbingox/acex 0.3.0 → 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 +1 -1
- package/src/adapters/binance/adapter.ts +131 -12
- package/src/adapters/binance/stream-protocol.ts +202 -0
- package/src/client/runtime.ts +1 -1
- package/src/internal/managed-websocket.ts +8 -0
- package/src/internal/subscription-multiplexer.ts +747 -0
- package/src/managers/market-manager.ts +63 -25
- package/src/adapters/binance/book-ticker.ts +0 -123
- package/src/adapters/binance/mark-price.ts +0 -126
package/package.json
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
77
|
+
const handle = this.getMultiplexer(options).subscribe(
|
|
78
|
+
{
|
|
79
|
+
channel: "l1book",
|
|
80
|
+
market: binanceMarket,
|
|
81
|
+
},
|
|
54
82
|
{
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
77
|
-
binanceMarket,
|
|
121
|
+
const handle = this.getMultiplexer(options).subscribe(
|
|
78
122
|
{
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -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,
|
|
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;
|