@imbingox/acex 0.1.0-beta.1 → 0.1.0-beta.3
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/README.md +664 -7
- package/package.json +26 -2
- package/src/adapters/binance/market-catalog.ts +16 -9
- package/src/adapters/binance/private-adapter.ts +833 -0
- package/src/adapters/types.ts +145 -1
- package/src/client/context.ts +63 -0
- package/src/client/private-subscription-coordinator.ts +512 -0
- package/src/client/runtime.ts +129 -2
- package/src/errors.ts +7 -1
- package/src/index.ts +1 -0
- package/src/internal/filters.ts +12 -14
- package/src/internal/managed-websocket.ts +24 -2
- package/src/managers/account-manager.ts +346 -52
- package/src/managers/market-manager.ts +21 -10
- package/src/managers/order-manager.ts +427 -46
- package/src/types/account.ts +16 -19
- package/src/types/market.ts +16 -14
- package/src/types/order.ts +47 -12
- package/src/types/shared.ts +15 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PrivateUserDataAdapter,
|
|
3
|
+
StreamHandle,
|
|
4
|
+
} from "../adapters/types.ts";
|
|
5
|
+
import { AcexError } from "../errors.ts";
|
|
6
|
+
import type { AccountRuntimeOptions, Exchange } from "../types/index.ts";
|
|
7
|
+
import type {
|
|
8
|
+
ClientContext,
|
|
9
|
+
PrivateAccountDataConsumer,
|
|
10
|
+
PrivateOrderDataConsumer,
|
|
11
|
+
RegisteredAccountRecord,
|
|
12
|
+
} from "./context.ts";
|
|
13
|
+
|
|
14
|
+
interface PrivateSubscriptionRecord {
|
|
15
|
+
accountId: string;
|
|
16
|
+
exchange: Exchange;
|
|
17
|
+
accountSubscribed: boolean;
|
|
18
|
+
ordersSubscribed: boolean;
|
|
19
|
+
accountReady: boolean;
|
|
20
|
+
orderReady: boolean;
|
|
21
|
+
stream?: StreamHandle;
|
|
22
|
+
startPromise?: Promise<void>;
|
|
23
|
+
reconcilePromise?: Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_STREAM_OPEN_TIMEOUT_MS = 15_000;
|
|
27
|
+
const DEFAULT_STREAM_RECONNECT_DELAY_MS = 1_000;
|
|
28
|
+
const DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS = 10_000;
|
|
29
|
+
const DEFAULT_LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1_000;
|
|
30
|
+
|
|
31
|
+
export class PrivateSubscriptionCoordinator {
|
|
32
|
+
private readonly context: ClientContext;
|
|
33
|
+
private readonly adapter: PrivateUserDataAdapter;
|
|
34
|
+
private readonly accountConsumer: PrivateAccountDataConsumer;
|
|
35
|
+
private readonly orderConsumer: PrivateOrderDataConsumer;
|
|
36
|
+
private readonly streamOpenTimeoutMs: number;
|
|
37
|
+
private readonly streamReconnectDelayMs: number;
|
|
38
|
+
private readonly streamReconnectMaxDelayMs: number;
|
|
39
|
+
private readonly listenKeyKeepAliveMs: number;
|
|
40
|
+
private readonly records = new Map<string, PrivateSubscriptionRecord>();
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
context: ClientContext,
|
|
44
|
+
adapter: PrivateUserDataAdapter,
|
|
45
|
+
accountConsumer: PrivateAccountDataConsumer,
|
|
46
|
+
orderConsumer: PrivateOrderDataConsumer,
|
|
47
|
+
options: AccountRuntimeOptions = {},
|
|
48
|
+
) {
|
|
49
|
+
this.context = context;
|
|
50
|
+
this.adapter = adapter;
|
|
51
|
+
this.accountConsumer = accountConsumer;
|
|
52
|
+
this.orderConsumer = orderConsumer;
|
|
53
|
+
this.streamOpenTimeoutMs =
|
|
54
|
+
options.streamOpenTimeoutMs ?? DEFAULT_STREAM_OPEN_TIMEOUT_MS;
|
|
55
|
+
this.streamReconnectDelayMs =
|
|
56
|
+
options.streamReconnectDelayMs ?? DEFAULT_STREAM_RECONNECT_DELAY_MS;
|
|
57
|
+
this.streamReconnectMaxDelayMs =
|
|
58
|
+
options.streamReconnectMaxDelayMs ??
|
|
59
|
+
DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS;
|
|
60
|
+
this.listenKeyKeepAliveMs =
|
|
61
|
+
options.listenKeyKeepAliveMs ?? DEFAULT_LISTEN_KEY_KEEPALIVE_MS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async subscribeAccountFeed(accountId: string): Promise<void> {
|
|
65
|
+
const account = this.getAccount(accountId);
|
|
66
|
+
const record = this.getOrCreateRecord(account);
|
|
67
|
+
const needsPending = !record.stream && !record.startPromise;
|
|
68
|
+
record.accountSubscribed = true;
|
|
69
|
+
if (needsPending) {
|
|
70
|
+
this.accountConsumer.onPrivateAccountPending(accountId, record.exchange);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await this.ensureStream(record, account);
|
|
75
|
+
await this.bootstrapAccount(record, account);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
record.accountSubscribed = false;
|
|
78
|
+
this.closeIfUnused(record);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
unsubscribeAccountFeed(accountId: string): void {
|
|
84
|
+
const record = this.records.get(accountId);
|
|
85
|
+
if (!record) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
record.accountSubscribed = false;
|
|
90
|
+
this.closeIfUnused(record);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async subscribeOrderFeed(accountId: string): Promise<void> {
|
|
94
|
+
const account = this.getAccount(accountId);
|
|
95
|
+
const record = this.getOrCreateRecord(account);
|
|
96
|
+
const needsPending = !record.stream && !record.startPromise;
|
|
97
|
+
record.ordersSubscribed = true;
|
|
98
|
+
if (needsPending) {
|
|
99
|
+
this.orderConsumer.onPrivateOrderPending(accountId, record.exchange);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await this.ensureStream(record, account);
|
|
104
|
+
await this.bootstrapOrders(record, account);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
record.ordersSubscribed = false;
|
|
107
|
+
this.closeIfUnused(record);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
unsubscribeOrderFeed(accountId: string): void {
|
|
113
|
+
const record = this.records.get(accountId);
|
|
114
|
+
if (!record) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
record.ordersSubscribed = false;
|
|
119
|
+
this.closeIfUnused(record);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onClientStarted(): void {
|
|
123
|
+
for (const record of this.records.values()) {
|
|
124
|
+
if (!this.isActive(record)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (record.accountSubscribed) {
|
|
129
|
+
this.accountConsumer.onPrivateAccountPending(
|
|
130
|
+
record.accountId,
|
|
131
|
+
record.exchange,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (record.ordersSubscribed) {
|
|
135
|
+
this.orderConsumer.onPrivateOrderPending(
|
|
136
|
+
record.accountId,
|
|
137
|
+
record.exchange,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
void this.resumeRecord(record);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
onClientStopping(): void {
|
|
146
|
+
for (const record of this.records.values()) {
|
|
147
|
+
this.closeStream(record);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
onAccountRemoved(accountId: string): void {
|
|
152
|
+
const record = this.records.get(accountId);
|
|
153
|
+
if (!record) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.closeStream(record);
|
|
158
|
+
this.records.delete(accountId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onCredentialsUpdated(accountId: string): void {
|
|
162
|
+
const record = this.records.get(accountId);
|
|
163
|
+
if (!record || !this.isActive(record)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (record.accountSubscribed) {
|
|
168
|
+
this.accountConsumer.onPrivateAccountPending(accountId, record.exchange);
|
|
169
|
+
}
|
|
170
|
+
if (record.ordersSubscribed) {
|
|
171
|
+
this.orderConsumer.onPrivateOrderPending(accountId, record.exchange);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
void this.resumeRecord(record);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async resumeRecord(record: PrivateSubscriptionRecord): Promise<void> {
|
|
178
|
+
const account = this.getAccount(record.accountId);
|
|
179
|
+
this.closeStream(record);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await this.ensureStream(record, account);
|
|
183
|
+
if (record.accountSubscribed) {
|
|
184
|
+
await this.bootstrapAccount(record, account);
|
|
185
|
+
}
|
|
186
|
+
if (record.ordersSubscribed) {
|
|
187
|
+
await this.bootstrapOrders(record, account);
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Errors are already published to the runtime error bus.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private getAccount(accountId: string): RegisteredAccountRecord {
|
|
195
|
+
const account = this.context.getRegisteredAccount(accountId);
|
|
196
|
+
if (account.exchange !== this.adapter.exchange) {
|
|
197
|
+
throw new AcexError(
|
|
198
|
+
"EXCHANGE_NOT_SUPPORTED",
|
|
199
|
+
`Exchange is not supported yet: ${account.exchange}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return account;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private getOrCreateRecord(
|
|
207
|
+
account: RegisteredAccountRecord,
|
|
208
|
+
): PrivateSubscriptionRecord {
|
|
209
|
+
const existing = this.records.get(account.accountId);
|
|
210
|
+
if (existing) {
|
|
211
|
+
return existing;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const record: PrivateSubscriptionRecord = {
|
|
215
|
+
accountId: account.accountId,
|
|
216
|
+
exchange: account.exchange,
|
|
217
|
+
accountSubscribed: false,
|
|
218
|
+
ordersSubscribed: false,
|
|
219
|
+
accountReady: false,
|
|
220
|
+
orderReady: false,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
this.records.set(account.accountId, record);
|
|
224
|
+
return record;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private isActive(record: PrivateSubscriptionRecord): boolean {
|
|
228
|
+
return record.accountSubscribed || record.ordersSubscribed;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private closeIfUnused(record: PrivateSubscriptionRecord): void {
|
|
232
|
+
if (this.isActive(record)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.closeStream(record);
|
|
237
|
+
this.records.delete(record.accountId);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private closeStream(record: PrivateSubscriptionRecord): void {
|
|
241
|
+
record.stream?.close();
|
|
242
|
+
record.stream = undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async ensureStream(
|
|
246
|
+
record: PrivateSubscriptionRecord,
|
|
247
|
+
account: RegisteredAccountRecord,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (record.stream) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!record.startPromise) {
|
|
254
|
+
record.startPromise = this.startStream(record, account);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
await record.startPromise;
|
|
259
|
+
} finally {
|
|
260
|
+
if (record.startPromise) {
|
|
261
|
+
record.startPromise = undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async startStream(
|
|
267
|
+
record: PrivateSubscriptionRecord,
|
|
268
|
+
account: RegisteredAccountRecord,
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const credentials = account.credentials;
|
|
271
|
+
if (!credentials) {
|
|
272
|
+
throw new AcexError(
|
|
273
|
+
"CREDENTIALS_MISSING",
|
|
274
|
+
`Account credentials are required for private subscriptions: ${account.accountId}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const stream = this.adapter.createPrivateStream(
|
|
279
|
+
credentials,
|
|
280
|
+
{
|
|
281
|
+
onAccountUpdate: (update) => {
|
|
282
|
+
if (!record.accountSubscribed) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
record.accountReady = true;
|
|
287
|
+
this.accountConsumer.onPrivateAccountUpdate(
|
|
288
|
+
record.accountId,
|
|
289
|
+
record.exchange,
|
|
290
|
+
update,
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
onOrderUpdate: (update) => {
|
|
294
|
+
if (!record.ordersSubscribed) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
record.orderReady = true;
|
|
299
|
+
this.orderConsumer.onPrivateOrderUpdate(
|
|
300
|
+
record.accountId,
|
|
301
|
+
record.exchange,
|
|
302
|
+
update,
|
|
303
|
+
);
|
|
304
|
+
},
|
|
305
|
+
onDisconnected: () => {
|
|
306
|
+
if (record.accountSubscribed) {
|
|
307
|
+
this.accountConsumer.onPrivateAccountStreamState(
|
|
308
|
+
record.accountId,
|
|
309
|
+
record.exchange,
|
|
310
|
+
{
|
|
311
|
+
runtimeStatus: "reconnecting",
|
|
312
|
+
ready: record.accountReady,
|
|
313
|
+
reason: "ws_disconnected",
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (record.ordersSubscribed) {
|
|
318
|
+
this.orderConsumer.onPrivateOrderStreamState(
|
|
319
|
+
record.accountId,
|
|
320
|
+
record.exchange,
|
|
321
|
+
{
|
|
322
|
+
runtimeStatus: "reconnecting",
|
|
323
|
+
ready: record.orderReady,
|
|
324
|
+
reason: "ws_disconnected",
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
onReconnected: () => {
|
|
330
|
+
if (!record.reconcilePromise) {
|
|
331
|
+
record.reconcilePromise = this.reconcileRecord(record)
|
|
332
|
+
.catch(() => {})
|
|
333
|
+
.finally(() => {
|
|
334
|
+
record.reconcilePromise = undefined;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
onError: (error) => {
|
|
339
|
+
this.context.publishRuntimeError("adapter", error, {
|
|
340
|
+
accountId: record.accountId,
|
|
341
|
+
exchange: record.exchange,
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
openTimeoutMs: this.streamOpenTimeoutMs,
|
|
347
|
+
reconnectDelayMs: this.streamReconnectDelayMs,
|
|
348
|
+
reconnectMaxDelayMs: this.streamReconnectMaxDelayMs,
|
|
349
|
+
listenKeyKeepAliveMs: this.listenKeyKeepAliveMs,
|
|
350
|
+
now: () => this.context.now(),
|
|
351
|
+
},
|
|
352
|
+
account.options,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
record.stream = stream;
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
await stream.ready;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.closeStream(record);
|
|
361
|
+
const runtimeError =
|
|
362
|
+
error instanceof Error
|
|
363
|
+
? error
|
|
364
|
+
: new Error("Failed to open Binance private stream");
|
|
365
|
+
this.context.publishRuntimeError("adapter", runtimeError, {
|
|
366
|
+
accountId: record.accountId,
|
|
367
|
+
exchange: record.exchange,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (record.accountSubscribed) {
|
|
371
|
+
this.accountConsumer.onPrivateAccountStreamState(
|
|
372
|
+
record.accountId,
|
|
373
|
+
record.exchange,
|
|
374
|
+
{
|
|
375
|
+
runtimeStatus: "degraded",
|
|
376
|
+
ready: record.accountReady,
|
|
377
|
+
reason: "ws_disconnected",
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
if (record.ordersSubscribed) {
|
|
382
|
+
this.orderConsumer.onPrivateOrderStreamState(
|
|
383
|
+
record.accountId,
|
|
384
|
+
record.exchange,
|
|
385
|
+
{
|
|
386
|
+
runtimeStatus: "degraded",
|
|
387
|
+
ready: record.orderReady,
|
|
388
|
+
reason: "ws_disconnected",
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
throw runtimeError;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async reconcileRecord(
|
|
398
|
+
record: PrivateSubscriptionRecord,
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
const account = this.getAccount(record.accountId);
|
|
401
|
+
|
|
402
|
+
if (record.accountSubscribed) {
|
|
403
|
+
this.accountConsumer.onPrivateAccountPending(
|
|
404
|
+
record.accountId,
|
|
405
|
+
record.exchange,
|
|
406
|
+
);
|
|
407
|
+
await this.bootstrapAccount(record, account);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (record.ordersSubscribed) {
|
|
411
|
+
this.orderConsumer.onPrivateOrderPending(
|
|
412
|
+
record.accountId,
|
|
413
|
+
record.exchange,
|
|
414
|
+
);
|
|
415
|
+
await this.bootstrapOrders(record, account);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async bootstrapAccount(
|
|
420
|
+
record: PrivateSubscriptionRecord,
|
|
421
|
+
account: RegisteredAccountRecord,
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
try {
|
|
424
|
+
const bootstrap = await this.adapter.bootstrapAccount(
|
|
425
|
+
account.credentials ?? {},
|
|
426
|
+
account.options,
|
|
427
|
+
);
|
|
428
|
+
if (!record.accountSubscribed) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
record.accountReady = true;
|
|
433
|
+
this.accountConsumer.onPrivateAccountBootstrap(
|
|
434
|
+
record.accountId,
|
|
435
|
+
record.exchange,
|
|
436
|
+
bootstrap,
|
|
437
|
+
);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
record.accountReady = false;
|
|
440
|
+
this.context.publishRuntimeError(
|
|
441
|
+
"adapter",
|
|
442
|
+
error instanceof Error
|
|
443
|
+
? error
|
|
444
|
+
: new Error("Failed to bootstrap Binance private account state"),
|
|
445
|
+
{
|
|
446
|
+
accountId: record.accountId,
|
|
447
|
+
exchange: record.exchange,
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
this.accountConsumer.onPrivateAccountStreamState(
|
|
451
|
+
record.accountId,
|
|
452
|
+
record.exchange,
|
|
453
|
+
{
|
|
454
|
+
runtimeStatus: "degraded",
|
|
455
|
+
ready: false,
|
|
456
|
+
reason: "auth_failed",
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
throw new AcexError(
|
|
460
|
+
"ACCOUNT_BOOTSTRAP_FAILED",
|
|
461
|
+
`Failed to bootstrap account data: ${record.accountId}`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async bootstrapOrders(
|
|
467
|
+
record: PrivateSubscriptionRecord,
|
|
468
|
+
account: RegisteredAccountRecord,
|
|
469
|
+
): Promise<void> {
|
|
470
|
+
try {
|
|
471
|
+
const snapshots = await this.adapter.bootstrapOpenOrders(
|
|
472
|
+
account.credentials ?? {},
|
|
473
|
+
account.options,
|
|
474
|
+
);
|
|
475
|
+
if (!record.ordersSubscribed) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
record.orderReady = true;
|
|
480
|
+
this.orderConsumer.onPrivateOrderBootstrap(
|
|
481
|
+
record.accountId,
|
|
482
|
+
record.exchange,
|
|
483
|
+
snapshots,
|
|
484
|
+
);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
record.orderReady = false;
|
|
487
|
+
this.context.publishRuntimeError(
|
|
488
|
+
"adapter",
|
|
489
|
+
error instanceof Error
|
|
490
|
+
? error
|
|
491
|
+
: new Error("Failed to bootstrap Binance private order state"),
|
|
492
|
+
{
|
|
493
|
+
accountId: record.accountId,
|
|
494
|
+
exchange: record.exchange,
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
this.orderConsumer.onPrivateOrderStreamState(
|
|
498
|
+
record.accountId,
|
|
499
|
+
record.exchange,
|
|
500
|
+
{
|
|
501
|
+
runtimeStatus: "degraded",
|
|
502
|
+
ready: false,
|
|
503
|
+
reason: "auth_failed",
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
throw new AcexError(
|
|
507
|
+
"ORDER_BOOTSTRAP_FAILED",
|
|
508
|
+
`Failed to bootstrap order data: ${record.accountId}`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { BinanceMarketAdapter } from "../adapters/binance/adapter.ts";
|
|
2
|
+
import { BinancePrivateAdapter } from "../adapters/binance/private-adapter.ts";
|
|
3
|
+
import type {
|
|
4
|
+
CancelAllOrdersRequest,
|
|
5
|
+
CancelOrderRequest,
|
|
6
|
+
CreateOrderRequest,
|
|
7
|
+
PrivateUserDataAdapter,
|
|
8
|
+
RawOrderUpdate,
|
|
9
|
+
} from "../adapters/types.ts";
|
|
2
10
|
import { AcexError, type AcexErrorCode } from "../errors.ts";
|
|
3
11
|
import { AsyncEventBus } from "../internal/async-event-bus.ts";
|
|
4
12
|
import { matchesHealthFilter } from "../internal/filters.ts";
|
|
@@ -10,11 +18,14 @@ import type {
|
|
|
10
18
|
AccountManager,
|
|
11
19
|
AcexClient,
|
|
12
20
|
AcexInternalError,
|
|
21
|
+
CancelAllOrdersInput,
|
|
22
|
+
CancelOrderInput,
|
|
13
23
|
ClientEventStreams,
|
|
14
24
|
ClientHealthSnapshot,
|
|
15
25
|
ClientStatus,
|
|
16
26
|
ClientStatusChangedEvent,
|
|
17
27
|
CreateClientOptions,
|
|
28
|
+
CreateOrderInput,
|
|
18
29
|
HealthEvent,
|
|
19
30
|
HealthEventFilter,
|
|
20
31
|
MarketManager,
|
|
@@ -27,8 +38,23 @@ import {
|
|
|
27
38
|
type ClientContext,
|
|
28
39
|
hasPrivateCredentials,
|
|
29
40
|
mergeCredentials,
|
|
41
|
+
type PrivateAccountDataConsumer,
|
|
42
|
+
type PrivateOrderDataConsumer,
|
|
30
43
|
type RegisteredAccountRecord,
|
|
31
44
|
} from "./context.ts";
|
|
45
|
+
import { PrivateSubscriptionCoordinator } from "./private-subscription-coordinator.ts";
|
|
46
|
+
|
|
47
|
+
const activeClients = new Set<AcexClientImpl>();
|
|
48
|
+
|
|
49
|
+
export function stopAllClientsForTests(): void {
|
|
50
|
+
const clients = [...activeClients];
|
|
51
|
+
activeClients.clear();
|
|
52
|
+
for (const client of clients) {
|
|
53
|
+
void client.stop().catch(() => {
|
|
54
|
+
// Test cleanup should be best-effort and never mask the original failure.
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
32
58
|
|
|
33
59
|
class ClientEventStreamsImpl implements ClientEventStreams {
|
|
34
60
|
constructor(
|
|
@@ -61,11 +87,16 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
61
87
|
private readonly marketManager: MarketManagerImpl;
|
|
62
88
|
private readonly accountManager: AccountManagerImpl;
|
|
63
89
|
private readonly orderManager: OrderManagerImpl;
|
|
90
|
+
private readonly privateAdapter: PrivateUserDataAdapter;
|
|
91
|
+
private readonly privateCoordinator: PrivateSubscriptionCoordinator;
|
|
64
92
|
|
|
65
93
|
constructor(options: CreateClientOptions = {}) {
|
|
66
|
-
|
|
94
|
+
activeClients.add(this);
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
const marketAdapter = new BinanceMarketAdapter();
|
|
97
|
+
this.privateAdapter = new BinancePrivateAdapter();
|
|
98
|
+
|
|
99
|
+
this.marketManager = new MarketManagerImpl(this, marketAdapter, {
|
|
69
100
|
initialL1TimeoutMs: options.market?.l1InitialMessageTimeoutMs,
|
|
70
101
|
l1StaleAfterMs: options.market?.l1StaleAfterMs,
|
|
71
102
|
l1ReconnectDelayMs: options.market?.l1ReconnectDelayMs,
|
|
@@ -73,6 +104,13 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
73
104
|
});
|
|
74
105
|
this.accountManager = new AccountManagerImpl(this);
|
|
75
106
|
this.orderManager = new OrderManagerImpl(this);
|
|
107
|
+
this.privateCoordinator = new PrivateSubscriptionCoordinator(
|
|
108
|
+
this,
|
|
109
|
+
this.privateAdapter,
|
|
110
|
+
this.accountManager as PrivateAccountDataConsumer,
|
|
111
|
+
this.orderManager as PrivateOrderDataConsumer,
|
|
112
|
+
options.account,
|
|
113
|
+
);
|
|
76
114
|
|
|
77
115
|
this.market = this.marketManager;
|
|
78
116
|
this.account = this.accountManager;
|
|
@@ -141,6 +179,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
141
179
|
|
|
142
180
|
this.accountManager.onCredentialsUpdated(accountId, account.exchange);
|
|
143
181
|
this.orderManager.onCredentialsUpdated(accountId, account.exchange);
|
|
182
|
+
this.privateCoordinator.onCredentialsUpdated(accountId);
|
|
144
183
|
}
|
|
145
184
|
|
|
146
185
|
async removeAccount(accountId: string): Promise<void> {
|
|
@@ -154,6 +193,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
154
193
|
}
|
|
155
194
|
|
|
156
195
|
const now = this.now();
|
|
196
|
+
this.privateCoordinator.onAccountRemoved(accountId);
|
|
157
197
|
this.accountManager.onAccountRemoved(accountId, now);
|
|
158
198
|
this.orderManager.onAccountRemoved(accountId, now);
|
|
159
199
|
this.registeredAccounts.delete(accountId);
|
|
@@ -170,6 +210,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
170
210
|
this.marketManager.onClientStarted();
|
|
171
211
|
this.accountManager.onClientStarted();
|
|
172
212
|
this.orderManager.onClientStarted();
|
|
213
|
+
this.privateCoordinator.onClientStarted();
|
|
173
214
|
}
|
|
174
215
|
|
|
175
216
|
async stop(_options?: StopOptions): Promise<void> {
|
|
@@ -183,6 +224,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
183
224
|
this.setClientStatus("stopping");
|
|
184
225
|
|
|
185
226
|
const now = this.now();
|
|
227
|
+
this.privateCoordinator.onClientStopping();
|
|
186
228
|
this.marketManager.onClientStopping(now);
|
|
187
229
|
this.accountManager.onClientStopping(now);
|
|
188
230
|
this.orderManager.onClientStopping(now);
|
|
@@ -231,6 +273,70 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
231
273
|
);
|
|
232
274
|
}
|
|
233
275
|
|
|
276
|
+
subscribePrivateAccountFeed(accountId: string): Promise<void> {
|
|
277
|
+
return this.privateCoordinator.subscribeAccountFeed(accountId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
unsubscribePrivateAccountFeed(accountId: string): void {
|
|
281
|
+
this.privateCoordinator.unsubscribeAccountFeed(accountId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
subscribePrivateOrderFeed(accountId: string): Promise<void> {
|
|
285
|
+
return this.privateCoordinator.subscribeOrderFeed(accountId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
unsubscribePrivateOrderFeed(accountId: string): void {
|
|
289
|
+
this.privateCoordinator.unsubscribeOrderFeed(accountId);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
createOrder(input: CreateOrderInput): Promise<RawOrderUpdate> {
|
|
293
|
+
const account = this.getPrivateCommandAccount(input.accountId);
|
|
294
|
+
const request: CreateOrderRequest = {
|
|
295
|
+
symbol: input.symbol,
|
|
296
|
+
side: input.side,
|
|
297
|
+
type: input.type,
|
|
298
|
+
amount: input.amount,
|
|
299
|
+
price: input.type === "limit" ? input.price : undefined,
|
|
300
|
+
clientOrderId: input.clientOrderId,
|
|
301
|
+
reduceOnly: input.reduceOnly,
|
|
302
|
+
positionSide: input.positionSide,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return this.privateAdapter.createOrder(
|
|
306
|
+
account.credentials ?? {},
|
|
307
|
+
request,
|
|
308
|
+
account.options,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
cancelOrder(input: CancelOrderInput): Promise<RawOrderUpdate> {
|
|
313
|
+
const account = this.getPrivateCommandAccount(input.accountId);
|
|
314
|
+
const request: CancelOrderRequest = {
|
|
315
|
+
symbol: input.symbol,
|
|
316
|
+
orderId: input.orderId,
|
|
317
|
+
clientOrderId: input.clientOrderId,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
return this.privateAdapter.cancelOrder(
|
|
321
|
+
account.credentials ?? {},
|
|
322
|
+
request,
|
|
323
|
+
account.options,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
cancelAllOrders(input: CancelAllOrdersInput): Promise<RawOrderUpdate[]> {
|
|
328
|
+
const account = this.getPrivateCommandAccount(input.accountId);
|
|
329
|
+
const request: CancelAllOrdersRequest = {
|
|
330
|
+
symbol: input.symbol,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return this.privateAdapter.cancelAllOrders(
|
|
334
|
+
account.credentials ?? {},
|
|
335
|
+
request,
|
|
336
|
+
account.options,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
234
340
|
publishRuntimeError(
|
|
235
341
|
source: AcexInternalError["source"],
|
|
236
342
|
error: Error,
|
|
@@ -280,4 +386,25 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
280
386
|
});
|
|
281
387
|
return error;
|
|
282
388
|
}
|
|
389
|
+
|
|
390
|
+
private getPrivateCommandAccount(accountId: string): RegisteredAccountRecord {
|
|
391
|
+
const account = this.getRegisteredAccount(accountId);
|
|
392
|
+
if (account.exchange !== this.privateAdapter.exchange) {
|
|
393
|
+
throw this.createError(
|
|
394
|
+
"EXCHANGE_NOT_SUPPORTED",
|
|
395
|
+
`Exchange is not supported yet: ${account.exchange}`,
|
|
396
|
+
{ accountId, exchange: account.exchange },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!hasPrivateCredentials(account.credentials)) {
|
|
401
|
+
throw this.createError(
|
|
402
|
+
"CREDENTIALS_MISSING",
|
|
403
|
+
`Account credentials are required for private order commands: ${accountId}`,
|
|
404
|
+
{ accountId, exchange: account.exchange },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return account;
|
|
409
|
+
}
|
|
283
410
|
}
|