@imbingox/acex 0.1.0-beta.2 → 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/src/errors.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type AcexErrorCode =
2
2
  | "ACCOUNT_ALREADY_EXISTS"
3
+ | "ACCOUNT_BOOTSTRAP_FAILED"
3
4
  | "ACCOUNT_NOT_FOUND"
4
5
  | "CLIENT_NOT_STARTED"
5
6
  | "CREDENTIALS_MISSING"
@@ -7,7 +8,12 @@ export type AcexErrorCode =
7
8
  | "MARKET_CATALOG_LOAD_FAILED"
8
9
  | "MARKET_INACTIVE"
9
10
  | "MARKET_NOT_FOUND"
10
- | "MARKET_STREAM_TIMEOUT";
11
+ | "MARKET_STREAM_TIMEOUT"
12
+ | "ORDER_BOOTSTRAP_FAILED"
13
+ | "ORDER_CANCEL_ALL_FAILED"
14
+ | "ORDER_CANCEL_FAILED"
15
+ | "ORDER_CREATE_FAILED"
16
+ | "ORDER_INPUT_INVALID";
11
17
 
12
18
  export class AcexError extends Error {
13
19
  readonly code: AcexErrorCode;
@@ -95,24 +95,22 @@ export function matchesHealthFilter(
95
95
  }
96
96
  }
97
97
 
98
- if (
99
- "exchange" in event &&
100
- filter.exchange &&
101
- event.exchange !== filter.exchange
102
- ) {
103
- return false;
98
+ if (filter.exchange) {
99
+ if (!("exchange" in event) || event.exchange !== filter.exchange) {
100
+ return false;
101
+ }
104
102
  }
105
103
 
106
- if (
107
- "accountId" in event &&
108
- filter.accountId &&
109
- event.accountId !== filter.accountId
110
- ) {
111
- return false;
104
+ if (filter.accountId) {
105
+ if (!("accountId" in event) || event.accountId !== filter.accountId) {
106
+ return false;
107
+ }
112
108
  }
113
109
 
114
- if ("symbol" in event && filter.symbol && event.symbol !== filter.symbol) {
115
- return false;
110
+ if (filter.symbol) {
111
+ if (!("symbol" in event) || event.symbol !== filter.symbol) {
112
+ return false;
113
+ }
116
114
  }
117
115
 
118
116
  return true;
@@ -11,14 +11,17 @@ export interface ManagedWebSocketReconnectOptions {
11
11
  initialDelayMs: number;
12
12
  maxDelayMs: number;
13
13
  backoffMultiplier?: number;
14
+ reconnectWithoutMessages?: boolean;
14
15
  }
15
16
 
16
17
  export interface ManagedWebSocketOptions<TMessage> {
17
18
  url: string;
18
19
  initialMessageTimeoutMs: number;
20
+ readyWhen?: "message" | "open";
19
21
  parseMessage(data: string): TMessage | undefined;
20
22
  onMessage(message: TMessage, receivedAt: number): void;
21
23
  onUnexpectedClose(event: CloseEvent): void;
24
+ onOpen?(): void;
22
25
  onError?(event: Event): void;
23
26
  messageWatchdog?: ManagedWebSocketWatchdogOptions;
24
27
  reconnect?: ManagedWebSocketReconnectOptions;
@@ -52,6 +55,7 @@ export function createManagedWebSocket<TMessage>(
52
55
  const messageWatchdog = options.messageWatchdog;
53
56
  const reconnect = options.reconnect;
54
57
  const reconnectMultiplier = reconnect?.backoffMultiplier ?? 2;
58
+ const readyWhen = options.readyWhen ?? "message";
55
59
 
56
60
  let closed = false;
57
61
  let staleNotified = false;
@@ -131,7 +135,12 @@ export function createManagedWebSocket<TMessage>(
131
135
  };
132
136
 
133
137
  const scheduleReconnect = () => {
134
- if (closed || !reconnect || !hasMessage || reconnectTimeout) {
138
+ if (
139
+ closed ||
140
+ !reconnect ||
141
+ reconnectTimeout ||
142
+ (!hasMessage && !reconnect.reconnectWithoutMessages)
143
+ ) {
135
144
  return;
136
145
  }
137
146
 
@@ -171,6 +180,17 @@ export function createManagedWebSocket<TMessage>(
171
180
  scheduleStaleTimeout();
172
181
  }
173
182
 
183
+ socket.addEventListener("open", () => {
184
+ if (closed || activeSocket !== socket) {
185
+ return;
186
+ }
187
+
188
+ options.onOpen?.();
189
+ if (readyWhen === "open") {
190
+ resolveIfPending();
191
+ }
192
+ });
193
+
174
194
  socket.addEventListener("message", (event) => {
175
195
  if (closed || activeSocket !== socket || typeof event.data !== "string") {
176
196
  return;
@@ -206,7 +226,9 @@ export function createManagedWebSocket<TMessage>(
206
226
  scheduleStaleTimeout();
207
227
  }
208
228
  options.onMessage(parsed, lastMessageAt);
209
- resolveIfPending();
229
+ if (readyWhen === "message") {
230
+ resolveIfPending();
231
+ }
210
232
  });
211
233
 
212
234
  socket.addEventListener("error", (event) => {
@@ -1,8 +1,18 @@
1
+ import BigNumber from "bignumber.js";
2
+ import type {
3
+ RawAccountBootstrap,
4
+ RawAccountUpdate,
5
+ RawBalanceUpdate,
6
+ RawPositionUpdate,
7
+ RawRiskUpdate,
8
+ } from "../adapters/types.ts";
1
9
  import type {
2
10
  AccountAwareManager,
3
11
  ClientContext,
4
12
  HealthReporter,
5
13
  ManagerLifecycle,
14
+ PrivateAccountDataConsumer,
15
+ PrivateSubscriptionState,
6
16
  } from "../client/context.ts";
7
17
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
8
18
  import { matchesAccountFilter } from "../internal/filters.ts";
@@ -35,12 +45,24 @@ function cloneAccountStatus(status: AccountDataStatus): AccountDataStatus {
35
45
  return { ...status };
36
46
  }
37
47
 
48
+ function positionKey(symbol: string, side: PositionSnapshot["side"]): string {
49
+ return `${symbol}:${side}`;
50
+ }
51
+
52
+ function getBigNumber(
53
+ value: string | undefined,
54
+ fallback: BigNumber,
55
+ ): BigNumber {
56
+ return value === undefined ? fallback : new BigNumber(value);
57
+ }
58
+
38
59
  export class AccountManagerImpl
39
60
  implements
40
61
  AccountManager,
41
62
  ManagerLifecycle,
42
63
  AccountAwareManager,
43
- HealthReporter<AccountDataStatus>
64
+ HealthReporter<AccountDataStatus>,
65
+ PrivateAccountDataConsumer
44
66
  {
45
67
  readonly events: AccountEventStreams;
46
68
 
@@ -84,28 +106,13 @@ export class AccountManagerImpl
84
106
 
85
107
  const record = this.getOrCreateRecord(input.accountId, account.exchange);
86
108
  record.subscribed = true;
87
- record.snapshot ??= this.createEmptySnapshot(
88
- input.accountId,
89
- account.exchange,
90
- );
91
- record.status = {
92
- ...this.createStatus(input.accountId, account.exchange, "active"),
93
- ready: true,
94
- runtimeStatus: "healthy",
95
- lastReceivedAt: record.snapshot.updatedAt,
96
- lastReadyAt: record.snapshot.updatedAt,
97
- };
98
-
99
- const event: AccountSnapshotReplacedEvent = {
100
- type: "account.snapshot_replaced",
101
- accountId: record.accountId,
102
- exchange: record.exchange,
103
- snapshot: record.snapshot,
104
- ts: this.context.now(),
105
- };
106
109
 
107
- this.accountBus.publish(event);
108
- this.publishStatus(record);
110
+ try {
111
+ await this.context.subscribePrivateAccountFeed(input.accountId);
112
+ } catch (error) {
113
+ record.subscribed = false;
114
+ throw error;
115
+ }
109
116
  }
110
117
 
111
118
  async unsubscribeAccount(input: UnsubscribeAccountInput): Promise<void> {
@@ -114,11 +121,13 @@ export class AccountManagerImpl
114
121
  return;
115
122
  }
116
123
 
124
+ this.context.unsubscribePrivateAccountFeed(input.accountId);
117
125
  record.subscribed = false;
118
126
  record.status = {
119
127
  ...record.status,
120
128
  activity: "inactive",
121
129
  runtimeStatus: "stopped",
130
+ reason: undefined,
122
131
  inactiveSince: this.context.now(),
123
132
  };
124
133
  this.publishStatus(record);
@@ -162,31 +171,7 @@ export class AccountManagerImpl
162
171
 
163
172
  // --- ManagerLifecycle ---
164
173
 
165
- onClientStarted(): void {
166
- const now = this.context.now();
167
-
168
- for (const [accountId, record] of this.records) {
169
- if (!record.subscribed) {
170
- continue;
171
- }
172
-
173
- const account = this.context.getRegisteredAccount(accountId);
174
- const creds = account.credentials;
175
- if (!creds?.apiKey || !creds.secret) {
176
- continue;
177
- }
178
-
179
- record.snapshot ??= this.createEmptySnapshot(accountId, account.exchange);
180
- record.status = {
181
- ...this.createStatus(accountId, account.exchange, "active"),
182
- ready: true,
183
- runtimeStatus: "healthy",
184
- lastReceivedAt: now,
185
- lastReadyAt: record.snapshot.updatedAt,
186
- };
187
- this.publishStatus(record);
188
- }
189
- }
174
+ onClientStarted(): void {}
190
175
 
191
176
  onClientStopping(now: number): void {
192
177
  for (const record of this.records.values()) {
@@ -198,6 +183,7 @@ export class AccountManagerImpl
198
183
  ...record.status,
199
184
  activity: "inactive",
200
185
  runtimeStatus: "stopped",
186
+ reason: undefined,
201
187
  inactiveSince: now,
202
188
  };
203
189
  this.publishStatus(record);
@@ -217,6 +203,7 @@ export class AccountManagerImpl
217
203
  ...record.status,
218
204
  activity: "inactive",
219
205
  runtimeStatus: "stopped",
206
+ reason: undefined,
220
207
  inactiveSince: now,
221
208
  };
222
209
  this.publishStatus(record);
@@ -229,11 +216,185 @@ export class AccountManagerImpl
229
216
  return;
230
217
  }
231
218
 
232
- record.status = this.createStatus(accountId, exchange, "active");
233
- record.status.ready = Boolean(record.snapshot);
234
- record.status.runtimeStatus = "healthy";
235
- record.status.lastReadyAt =
236
- record.snapshot?.updatedAt ?? this.context.now();
219
+ this.onPrivateAccountPending(accountId, exchange);
220
+ }
221
+
222
+ // --- PrivateAccountDataConsumer ---
223
+
224
+ onPrivateAccountPending(accountId: string, exchange: Exchange): void {
225
+ const record = this.getOrCreateRecord(accountId, exchange);
226
+ if (!record.subscribed) {
227
+ return;
228
+ }
229
+
230
+ record.status = {
231
+ ...this.createStatus(accountId, exchange, "active"),
232
+ ready: Boolean(record.snapshot),
233
+ runtimeStatus: "bootstrap_pending",
234
+ reason: undefined,
235
+ lastReceivedAt: record.snapshot?.updatedAt,
236
+ lastReadyAt: record.snapshot?.updatedAt,
237
+ inactiveSince: undefined,
238
+ };
239
+ this.publishStatus(record);
240
+ }
241
+
242
+ onPrivateAccountBootstrap(
243
+ accountId: string,
244
+ exchange: Exchange,
245
+ bootstrap: RawAccountBootstrap,
246
+ ): void {
247
+ const record = this.getOrCreateRecord(accountId, exchange);
248
+ if (!record.subscribed) {
249
+ return;
250
+ }
251
+
252
+ record.snapshot = this.createBootstrapSnapshot(
253
+ accountId,
254
+ exchange,
255
+ bootstrap,
256
+ );
257
+ record.status = {
258
+ ...record.status,
259
+ activity: "active",
260
+ ready: true,
261
+ runtimeStatus: "healthy",
262
+ reason: undefined,
263
+ lastReceivedAt: record.snapshot.receivedAt,
264
+ lastReadyAt: record.snapshot.updatedAt,
265
+ inactiveSince: undefined,
266
+ };
267
+
268
+ const event: AccountSnapshotReplacedEvent = {
269
+ type: "account.snapshot_replaced",
270
+ accountId,
271
+ exchange,
272
+ snapshot: record.snapshot,
273
+ ts: this.context.now(),
274
+ };
275
+
276
+ this.accountBus.publish(event);
277
+ this.publishStatus(record);
278
+ }
279
+
280
+ onPrivateAccountUpdate(
281
+ accountId: string,
282
+ exchange: Exchange,
283
+ update: RawAccountUpdate,
284
+ ): void {
285
+ const record = this.getOrCreateRecord(accountId, exchange);
286
+ if (!record.subscribed) {
287
+ return;
288
+ }
289
+
290
+ const previous =
291
+ record.snapshot ?? this.createEmptySnapshot(accountId, exchange);
292
+ const balances = { ...previous.balances };
293
+ const positions = new Map(
294
+ previous.positions.map((position) => [
295
+ positionKey(position.symbol, position.side),
296
+ position,
297
+ ]),
298
+ );
299
+ let risk = previous.risk;
300
+
301
+ for (const balance of update.balances ?? []) {
302
+ const nextBalance = this.createBalance(
303
+ accountId,
304
+ exchange,
305
+ balance,
306
+ balances[balance.asset],
307
+ );
308
+ balances[balance.asset] = nextBalance;
309
+ this.accountBus.publish({
310
+ type: "balance.updated",
311
+ accountId,
312
+ exchange,
313
+ asset: balance.asset,
314
+ snapshot: nextBalance,
315
+ ts: this.context.now(),
316
+ });
317
+ }
318
+
319
+ for (const position of update.positions ?? []) {
320
+ const key = positionKey(position.symbol, position.side);
321
+ const nextPosition = this.createPosition(
322
+ accountId,
323
+ exchange,
324
+ position,
325
+ positions.get(key),
326
+ );
327
+
328
+ if (nextPosition.size.isZero()) {
329
+ positions.delete(key);
330
+ } else {
331
+ positions.set(key, nextPosition);
332
+ }
333
+
334
+ this.accountBus.publish({
335
+ type: "position.updated",
336
+ accountId,
337
+ exchange,
338
+ symbol: position.symbol,
339
+ snapshot: nextPosition,
340
+ ts: this.context.now(),
341
+ });
342
+ }
343
+
344
+ if (update.risk) {
345
+ risk = this.createRisk(accountId, exchange, update.risk, previous.risk);
346
+ this.accountBus.publish({
347
+ type: "risk.updated",
348
+ accountId,
349
+ exchange,
350
+ snapshot: risk,
351
+ ts: this.context.now(),
352
+ });
353
+ }
354
+
355
+ record.snapshot = {
356
+ accountId,
357
+ exchange,
358
+ balances,
359
+ positions: [...positions.values()],
360
+ risk,
361
+ exchangeTs: update.exchangeTs ?? previous.exchangeTs,
362
+ receivedAt: update.receivedAt,
363
+ updatedAt: update.receivedAt,
364
+ };
365
+ record.status = {
366
+ ...record.status,
367
+ activity: "active",
368
+ ready: true,
369
+ runtimeStatus: "healthy",
370
+ reason: undefined,
371
+ lastReceivedAt: update.receivedAt,
372
+ lastReadyAt: update.receivedAt,
373
+ inactiveSince: undefined,
374
+ };
375
+ this.publishStatus(record);
376
+ }
377
+
378
+ onPrivateAccountStreamState(
379
+ accountId: string,
380
+ exchange: Exchange,
381
+ state: PrivateSubscriptionState,
382
+ ): void {
383
+ const record = this.getOrCreateRecord(accountId, exchange);
384
+ if (!record.subscribed) {
385
+ return;
386
+ }
387
+
388
+ record.status = {
389
+ ...record.status,
390
+ activity: "active",
391
+ ready: state.ready,
392
+ runtimeStatus: state.runtimeStatus,
393
+ reason: state.reason,
394
+ lastReceivedAt: state.lastReceivedAt ?? record.status.lastReceivedAt,
395
+ lastReadyAt: state.lastReadyAt ?? record.status.lastReadyAt,
396
+ inactiveSince: undefined,
397
+ };
237
398
  this.publishStatus(record);
238
399
  }
239
400
 
@@ -285,6 +446,36 @@ export class AccountManagerImpl
285
446
  };
286
447
  }
287
448
 
449
+ private createBootstrapSnapshot(
450
+ accountId: string,
451
+ exchange: Exchange,
452
+ bootstrap: RawAccountBootstrap,
453
+ ): AccountSnapshot {
454
+ const balances = Object.fromEntries(
455
+ bootstrap.balances.map((balance) => [
456
+ balance.asset,
457
+ this.createBalance(accountId, exchange, balance),
458
+ ]),
459
+ );
460
+ const positions = bootstrap.positions
461
+ .map((position) => this.createPosition(accountId, exchange, position))
462
+ .filter((position) => !position.size.isZero());
463
+ const risk = bootstrap.risk
464
+ ? this.createRisk(accountId, exchange, bootstrap.risk)
465
+ : undefined;
466
+
467
+ return {
468
+ accountId,
469
+ exchange,
470
+ balances,
471
+ positions,
472
+ risk,
473
+ exchangeTs: bootstrap.exchangeTs,
474
+ receivedAt: bootstrap.receivedAt,
475
+ updatedAt: bootstrap.receivedAt,
476
+ };
477
+ }
478
+
288
479
  private createEmptySnapshot(
289
480
  accountId: string,
290
481
  exchange: Exchange,
@@ -300,6 +491,109 @@ export class AccountManagerImpl
300
491
  };
301
492
  }
302
493
 
494
+ private createBalance(
495
+ accountId: string,
496
+ exchange: Exchange,
497
+ input: RawBalanceUpdate,
498
+ previous?: BalanceSnapshot,
499
+ ): BalanceSnapshot {
500
+ const previousFree = previous?.free ?? new BigNumber(0);
501
+ const previousUsed = previous?.used ?? new BigNumber(0);
502
+ const previousTotal = previous?.total ?? previousFree.plus(previousUsed);
503
+ const free = getBigNumber(input.free, previousFree);
504
+ const total = getBigNumber(input.total, previousTotal);
505
+ const used =
506
+ input.used !== undefined
507
+ ? new BigNumber(input.used)
508
+ : input.total !== undefined || input.free !== undefined
509
+ ? total.minus(free)
510
+ : previousUsed;
511
+
512
+ return {
513
+ accountId,
514
+ exchange,
515
+ asset: input.asset,
516
+ free,
517
+ used,
518
+ total,
519
+ exchangeTs: input.exchangeTs,
520
+ receivedAt: input.receivedAt,
521
+ updatedAt: input.receivedAt,
522
+ seq: (previous?.seq ?? 0) + 1,
523
+ };
524
+ }
525
+
526
+ private createPosition(
527
+ accountId: string,
528
+ exchange: Exchange,
529
+ input: RawPositionUpdate,
530
+ previous?: PositionSnapshot,
531
+ ): PositionSnapshot {
532
+ return {
533
+ accountId,
534
+ exchange,
535
+ symbol: input.symbol,
536
+ side: input.side,
537
+ size: new BigNumber(input.size),
538
+ entryPrice:
539
+ input.entryPrice === undefined
540
+ ? previous?.entryPrice
541
+ : new BigNumber(input.entryPrice),
542
+ markPrice:
543
+ input.markPrice === undefined
544
+ ? previous?.markPrice
545
+ : new BigNumber(input.markPrice),
546
+ unrealizedPnl:
547
+ input.unrealizedPnl === undefined
548
+ ? previous?.unrealizedPnl
549
+ : new BigNumber(input.unrealizedPnl),
550
+ leverage:
551
+ input.leverage === undefined
552
+ ? previous?.leverage
553
+ : new BigNumber(input.leverage),
554
+ liquidationPrice:
555
+ input.liquidationPrice === undefined
556
+ ? previous?.liquidationPrice
557
+ : new BigNumber(input.liquidationPrice),
558
+ exchangeTs: input.exchangeTs,
559
+ receivedAt: input.receivedAt,
560
+ updatedAt: input.receivedAt,
561
+ seq: (previous?.seq ?? 0) + 1,
562
+ };
563
+ }
564
+
565
+ private createRisk(
566
+ accountId: string,
567
+ exchange: Exchange,
568
+ input: RawRiskUpdate,
569
+ previous?: RiskSnapshot,
570
+ ): RiskSnapshot {
571
+ return {
572
+ accountId,
573
+ exchange,
574
+ equity:
575
+ input.equity === undefined
576
+ ? previous?.equity
577
+ : new BigNumber(input.equity),
578
+ marginRatio:
579
+ input.marginRatio === undefined
580
+ ? previous?.marginRatio
581
+ : new BigNumber(input.marginRatio),
582
+ initialMargin:
583
+ input.initialMargin === undefined
584
+ ? previous?.initialMargin
585
+ : new BigNumber(input.initialMargin),
586
+ maintenanceMargin:
587
+ input.maintenanceMargin === undefined
588
+ ? previous?.maintenanceMargin
589
+ : new BigNumber(input.maintenanceMargin),
590
+ exchangeTs: input.exchangeTs,
591
+ receivedAt: input.receivedAt,
592
+ updatedAt: input.receivedAt,
593
+ seq: (previous?.seq ?? 0) + 1,
594
+ };
595
+ }
596
+
303
597
  private publishStatus(record: AccountRecord): void {
304
598
  const event: AccountStatusChangedEvent = {
305
599
  type: "account.status_changed",