@imbingox/acex 0.4.0-beta.1 → 0.4.0-beta.10

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.
@@ -1,9 +1,21 @@
1
1
  import type {
2
+ FetchOrderRequest,
2
3
  PrivateUserDataAdapter,
4
+ RawOpenOrdersSnapshot,
3
5
  StreamHandle,
4
6
  } from "../adapters/types.ts";
5
- import { AcexError } from "../errors.ts";
6
- import type { AccountRuntimeOptions, Venue } from "../types/index.ts";
7
+ import {
8
+ AcexError,
9
+ buildAcexErrorDetails,
10
+ formatAcexErrorMessage,
11
+ } from "../errors.ts";
12
+ import { isTransportError } from "../internal/http-client.ts";
13
+ import type {
14
+ AccountRuntimeOptions,
15
+ OrderSnapshot,
16
+ PrivateRuntimeReason,
17
+ Venue,
18
+ } from "../types/index.ts";
7
19
  import type {
8
20
  ClientContext,
9
21
  PrivateAccountDataConsumer,
@@ -22,6 +34,11 @@ interface PrivateSubscriptionRecord {
22
34
  accountRefreshTimer?: ReturnType<typeof setTimeout>;
23
35
  accountRefreshInFlight?: Promise<void>;
24
36
  accountRefreshGeneration: number;
37
+ accountSubscriptionGeneration: number;
38
+ orderSubscriptionGeneration: number;
39
+ privateReconcileTimer?: ReturnType<typeof setTimeout>;
40
+ privateReconcileInFlight?: Promise<void>;
41
+ privateReconcileGeneration: number;
25
42
  startPromise?: Promise<void>;
26
43
  reconcilePromise?: Promise<void>;
27
44
  }
@@ -31,6 +48,9 @@ const DEFAULT_STREAM_RECONNECT_DELAY_MS = 1_000;
31
48
  const DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS = 10_000;
32
49
  const DEFAULT_LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1_000;
33
50
  const DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS = 5_000;
51
+ const DEFAULT_BINANCE_PRIVATE_RECONCILE_INTERVAL_MS = 60_000;
52
+ const MAX_ORDER_TERMINAL_BACKFILLS_PER_RECONCILE = 20;
53
+ const MAX_ORDER_TERMINAL_BACKFILL_CONCURRENCY = 4;
34
54
 
35
55
  function normalizePositiveInterval(
36
56
  value: number | undefined,
@@ -41,6 +61,26 @@ function normalizePositiveInterval(
41
61
  : fallback;
42
62
  }
43
63
 
64
+ function normalizeReconcileInterval(
65
+ value: number | undefined,
66
+ fallback: number,
67
+ ): number | undefined {
68
+ if (value === 0) {
69
+ return undefined;
70
+ }
71
+
72
+ return normalizePositiveInterval(value, fallback);
73
+ }
74
+
75
+ function transportReason(
76
+ error: unknown,
77
+ fallback: PrivateRuntimeReason,
78
+ ): PrivateRuntimeReason {
79
+ return isTransportError(error) && error.kind === "rate_limited"
80
+ ? "rate_limited"
81
+ : fallback;
82
+ }
83
+
44
84
  export class PrivateSubscriptionCoordinator {
45
85
  private readonly context: ClientContext;
46
86
  private readonly adapters: Map<Venue, PrivateUserDataAdapter>;
@@ -51,7 +91,7 @@ export class PrivateSubscriptionCoordinator {
51
91
  private readonly streamReconnectMaxDelayMs: number;
52
92
  private readonly listenKeyKeepAliveMs: number;
53
93
  private readonly binanceRiskPollIntervalMs: number;
54
- private readonly juplendPollIntervalMs?: number;
94
+ private readonly binancePrivateReconcileIntervalMs: number | undefined;
55
95
  private readonly records = new Map<string, PrivateSubscriptionRecord>();
56
96
 
57
97
  constructor(
@@ -80,7 +120,10 @@ export class PrivateSubscriptionCoordinator {
80
120
  options.binance?.riskPollIntervalMs,
81
121
  DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS,
82
122
  );
83
- this.juplendPollIntervalMs = options.juplend?.pollIntervalMs;
123
+ this.binancePrivateReconcileIntervalMs = normalizeReconcileInterval(
124
+ options.binance?.privateReconcileIntervalMs,
125
+ DEFAULT_BINANCE_PRIVATE_RECONCILE_INTERVAL_MS,
126
+ );
84
127
  }
85
128
 
86
129
  async subscribeAccountFeed(accountId: string): Promise<void> {
@@ -88,18 +131,69 @@ export class PrivateSubscriptionCoordinator {
88
131
  const record = this.getOrCreateRecord(account);
89
132
  const needsPending = !record.stream && !record.startPromise;
90
133
  record.accountSubscribed = true;
134
+ const generation = record.privateReconcileGeneration;
135
+ const accountGeneration = record.accountSubscriptionGeneration;
91
136
  if (needsPending) {
92
137
  this.accountConsumer.onPrivateAccountPending(accountId, record.venue);
93
138
  }
94
139
 
95
140
  try {
96
- if (record.venue === "juplend") {
97
- await this.bootstrapAccount(record, account);
141
+ const adapter = this.getAdapter(record.venue);
142
+ if (adapter.accountCapabilities.updates === "polling") {
143
+ await this.bootstrapAccount(
144
+ record,
145
+ account,
146
+ generation,
147
+ accountGeneration,
148
+ );
149
+ if (
150
+ !this.shouldContinueAccountBootstrap(
151
+ record,
152
+ generation,
153
+ accountGeneration,
154
+ )
155
+ ) {
156
+ return;
157
+ }
98
158
  await this.ensureStream(record, account);
159
+ if (
160
+ !this.shouldContinueAccountBootstrap(
161
+ record,
162
+ generation,
163
+ accountGeneration,
164
+ )
165
+ ) {
166
+ return;
167
+ }
168
+ this.ensurePrivateReconcilePolling(record);
99
169
  } else {
100
170
  await this.ensureStream(record, account);
101
- await this.bootstrapAccount(record, account);
171
+ if (
172
+ !this.shouldContinueAccountBootstrap(
173
+ record,
174
+ generation,
175
+ accountGeneration,
176
+ )
177
+ ) {
178
+ return;
179
+ }
180
+ await this.bootstrapAccount(
181
+ record,
182
+ account,
183
+ generation,
184
+ accountGeneration,
185
+ );
186
+ if (
187
+ !this.shouldContinueAccountBootstrap(
188
+ record,
189
+ generation,
190
+ accountGeneration,
191
+ )
192
+ ) {
193
+ return;
194
+ }
102
195
  this.ensureAccountRefreshPolling(record);
196
+ this.ensurePrivateReconcilePolling(record);
103
197
  }
104
198
  } catch (error) {
105
199
  record.accountSubscribed = false;
@@ -115,7 +209,9 @@ export class PrivateSubscriptionCoordinator {
115
209
  }
116
210
 
117
211
  record.accountSubscribed = false;
212
+ record.accountSubscriptionGeneration += 1;
118
213
  this.stopAccountRefreshPolling(record);
214
+ this.restartPrivateReconcilePolling(record);
119
215
  this.closeIfUnused(record);
120
216
  }
121
217
 
@@ -124,13 +220,26 @@ export class PrivateSubscriptionCoordinator {
124
220
  const record = this.getOrCreateRecord(account);
125
221
  const needsPending = !record.stream && !record.startPromise;
126
222
  record.ordersSubscribed = true;
223
+ const generation = record.privateReconcileGeneration;
224
+ const orderGeneration = record.orderSubscriptionGeneration;
127
225
  if (needsPending) {
128
226
  this.orderConsumer.onPrivateOrderPending(accountId, record.venue);
129
227
  }
130
228
 
131
229
  try {
132
230
  await this.ensureStream(record, account);
133
- await this.bootstrapOrders(record, account);
231
+ if (
232
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
233
+ ) {
234
+ return;
235
+ }
236
+ await this.bootstrapOrders(record, account, generation, orderGeneration);
237
+ if (
238
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
239
+ ) {
240
+ return;
241
+ }
242
+ this.ensurePrivateReconcilePolling(record);
134
243
  } catch (error) {
135
244
  record.ordersSubscribed = false;
136
245
  this.closeIfUnused(record);
@@ -145,6 +254,8 @@ export class PrivateSubscriptionCoordinator {
145
254
  }
146
255
 
147
256
  record.ordersSubscribed = false;
257
+ record.orderSubscriptionGeneration += 1;
258
+ this.restartPrivateReconcilePolling(record);
148
259
  this.closeIfUnused(record);
149
260
  }
150
261
 
@@ -174,6 +285,7 @@ export class PrivateSubscriptionCoordinator {
174
285
  onClientStopping(): void {
175
286
  for (const record of this.records.values()) {
176
287
  this.stopAccountRefreshPolling(record);
288
+ this.stopPrivateReconcilePolling(record);
177
289
  this.closeStream(record);
178
290
  }
179
291
  }
@@ -186,6 +298,7 @@ export class PrivateSubscriptionCoordinator {
186
298
 
187
299
  this.closeStream(record);
188
300
  this.stopAccountRefreshPolling(record);
301
+ this.stopPrivateReconcilePolling(record);
189
302
  this.records.delete(accountId);
190
303
  }
191
304
 
@@ -209,20 +322,82 @@ export class PrivateSubscriptionCoordinator {
209
322
  const account = this.getAccount(record.accountId);
210
323
  this.closeStream(record);
211
324
  this.stopAccountRefreshPolling(record);
325
+ this.stopPrivateReconcilePolling(record);
326
+ const generation = record.privateReconcileGeneration;
327
+ const accountGeneration = record.accountSubscriptionGeneration;
328
+ const orderGeneration = record.orderSubscriptionGeneration;
212
329
 
213
330
  try {
214
- if (record.venue === "juplend" && record.accountSubscribed) {
215
- await this.bootstrapAccount(record, account);
216
- await this.ensureStream(record, account);
331
+ const adapter = this.getAdapter(record.venue);
332
+ if (
333
+ adapter.accountCapabilities.updates === "polling" &&
334
+ record.accountSubscribed
335
+ ) {
336
+ await this.bootstrapAccount(
337
+ record,
338
+ account,
339
+ generation,
340
+ accountGeneration,
341
+ );
342
+ if (
343
+ this.shouldContinueAccountBootstrap(
344
+ record,
345
+ generation,
346
+ accountGeneration,
347
+ )
348
+ ) {
349
+ await this.ensureStream(record, account);
350
+ if (
351
+ this.shouldContinueAccountBootstrap(
352
+ record,
353
+ generation,
354
+ accountGeneration,
355
+ )
356
+ ) {
357
+ this.ensurePrivateReconcilePolling(record);
358
+ }
359
+ }
217
360
  } else {
218
361
  await this.ensureStream(record, account);
219
- if (record.accountSubscribed) {
220
- await this.bootstrapAccount(record, account);
362
+ if (
363
+ this.shouldContinueAccountBootstrap(
364
+ record,
365
+ generation,
366
+ accountGeneration,
367
+ )
368
+ ) {
369
+ await this.bootstrapAccount(
370
+ record,
371
+ account,
372
+ generation,
373
+ accountGeneration,
374
+ );
375
+ }
376
+ if (
377
+ this.shouldContinueAccountBootstrap(
378
+ record,
379
+ generation,
380
+ accountGeneration,
381
+ )
382
+ ) {
221
383
  this.ensureAccountRefreshPolling(record);
384
+ this.ensurePrivateReconcilePolling(record);
222
385
  }
223
386
  }
224
- if (record.ordersSubscribed) {
225
- await this.bootstrapOrders(record, account);
387
+ if (
388
+ this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
389
+ ) {
390
+ await this.bootstrapOrders(
391
+ record,
392
+ account,
393
+ generation,
394
+ orderGeneration,
395
+ );
396
+ }
397
+ if (
398
+ this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
399
+ ) {
400
+ this.ensurePrivateReconcilePolling(record);
226
401
  }
227
402
  } catch {
228
403
  // Errors are already published to the runtime error bus.
@@ -235,6 +410,7 @@ export class PrivateSubscriptionCoordinator {
235
410
  throw new AcexError(
236
411
  "VENUE_NOT_SUPPORTED",
237
412
  `Venue is not supported yet: ${account.venue}`,
413
+ { details: buildAcexErrorDetails({ venue: account.venue }) },
238
414
  );
239
415
  }
240
416
 
@@ -247,6 +423,7 @@ export class PrivateSubscriptionCoordinator {
247
423
  throw new AcexError(
248
424
  "VENUE_NOT_SUPPORTED",
249
425
  `Venue is not supported yet: ${venue}`,
426
+ { details: buildAcexErrorDetails({ venue }) },
250
427
  );
251
428
  }
252
429
 
@@ -269,6 +446,9 @@ export class PrivateSubscriptionCoordinator {
269
446
  accountReady: false,
270
447
  orderReady: false,
271
448
  accountRefreshGeneration: 0,
449
+ accountSubscriptionGeneration: 0,
450
+ orderSubscriptionGeneration: 0,
451
+ privateReconcileGeneration: 0,
272
452
  };
273
453
 
274
454
  this.records.set(account.accountId, record);
@@ -279,12 +459,37 @@ export class PrivateSubscriptionCoordinator {
279
459
  return record.accountSubscribed || record.ordersSubscribed;
280
460
  }
281
461
 
462
+ private shouldContinueAccountBootstrap(
463
+ record: PrivateSubscriptionRecord,
464
+ generation: number,
465
+ accountGeneration: number,
466
+ ): boolean {
467
+ return (
468
+ record.accountSubscribed &&
469
+ generation === record.privateReconcileGeneration &&
470
+ accountGeneration === record.accountSubscriptionGeneration
471
+ );
472
+ }
473
+
474
+ private shouldContinueOrderBootstrap(
475
+ record: PrivateSubscriptionRecord,
476
+ generation: number,
477
+ orderGeneration: number,
478
+ ): boolean {
479
+ return (
480
+ record.ordersSubscribed &&
481
+ generation === record.privateReconcileGeneration &&
482
+ orderGeneration === record.orderSubscriptionGeneration
483
+ );
484
+ }
485
+
282
486
  private closeIfUnused(record: PrivateSubscriptionRecord): void {
283
487
  if (this.isActive(record)) {
284
488
  return;
285
489
  }
286
490
 
287
491
  this.stopAccountRefreshPolling(record);
492
+ this.stopPrivateReconcilePolling(record);
288
493
  this.closeStream(record);
289
494
  this.records.delete(record.accountId);
290
495
  }
@@ -296,7 +501,7 @@ export class PrivateSubscriptionCoordinator {
296
501
 
297
502
  private ensureAccountRefreshPolling(record: PrivateSubscriptionRecord): void {
298
503
  if (
299
- record.venue !== "binance" ||
504
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
300
505
  !record.accountSubscribed ||
301
506
  record.accountRefreshTimer ||
302
507
  record.accountRefreshInFlight
@@ -317,7 +522,10 @@ export class PrivateSubscriptionCoordinator {
317
522
  }
318
523
 
319
524
  private scheduleAccountRefreshPoll(record: PrivateSubscriptionRecord): void {
320
- if (record.venue !== "binance" || !record.accountSubscribed) {
525
+ if (
526
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
527
+ !record.accountSubscribed
528
+ ) {
321
529
  return;
322
530
  }
323
531
 
@@ -326,7 +534,7 @@ export class PrivateSubscriptionCoordinator {
326
534
  record.accountRefreshTimer = undefined;
327
535
  if (
328
536
  generation !== record.accountRefreshGeneration ||
329
- record.venue !== "binance" ||
537
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
330
538
  !record.accountSubscribed
331
539
  ) {
332
540
  return;
@@ -352,13 +560,139 @@ export class PrivateSubscriptionCoordinator {
352
560
  }
353
561
 
354
562
  record.accountRefreshInFlight = undefined;
355
- if (record.accountSubscribed && record.venue === "binance") {
563
+ if (
564
+ record.accountSubscribed &&
565
+ typeof this.getAdapter(record.venue).refreshAccount === "function"
566
+ ) {
356
567
  this.scheduleAccountRefreshPoll(record);
357
568
  }
358
569
  });
359
570
  }, this.binanceRiskPollIntervalMs);
360
571
  }
361
572
 
573
+ private hasPrivateReconcileCapability(
574
+ record: PrivateSubscriptionRecord,
575
+ ): boolean {
576
+ const adapter = this.getAdapter(record.venue);
577
+ return (
578
+ (record.accountSubscribed &&
579
+ (typeof adapter.reconcileAccount === "function" ||
580
+ typeof adapter.bootstrapAccount === "function")) ||
581
+ (record.ordersSubscribed && typeof adapter.fetchOpenOrders === "function")
582
+ );
583
+ }
584
+
585
+ private ensurePrivateReconcilePolling(
586
+ record: PrivateSubscriptionRecord,
587
+ ): void {
588
+ if (
589
+ this.binancePrivateReconcileIntervalMs === undefined ||
590
+ !this.isActive(record) ||
591
+ !this.hasPrivateReconcileCapability(record) ||
592
+ record.privateReconcileTimer ||
593
+ record.privateReconcileInFlight
594
+ ) {
595
+ return;
596
+ }
597
+
598
+ this.schedulePrivateReconcilePoll(record);
599
+ }
600
+
601
+ private restartPrivateReconcilePolling(
602
+ record: PrivateSubscriptionRecord,
603
+ ): void {
604
+ if (record.privateReconcileTimer) {
605
+ clearTimeout(record.privateReconcileTimer);
606
+ record.privateReconcileTimer = undefined;
607
+ }
608
+ this.ensurePrivateReconcilePolling(record);
609
+ }
610
+
611
+ private stopPrivateReconcilePolling(record: PrivateSubscriptionRecord): void {
612
+ record.privateReconcileGeneration += 1;
613
+ if (record.privateReconcileTimer) {
614
+ clearTimeout(record.privateReconcileTimer);
615
+ record.privateReconcileTimer = undefined;
616
+ }
617
+ record.privateReconcileInFlight = undefined;
618
+ }
619
+
620
+ private schedulePrivateReconcilePoll(
621
+ record: PrivateSubscriptionRecord,
622
+ ): void {
623
+ if (
624
+ this.binancePrivateReconcileIntervalMs === undefined ||
625
+ !this.isActive(record) ||
626
+ !this.hasPrivateReconcileCapability(record)
627
+ ) {
628
+ return;
629
+ }
630
+
631
+ const generation = record.privateReconcileGeneration;
632
+ record.privateReconcileTimer = setTimeout(() => {
633
+ record.privateReconcileTimer = undefined;
634
+ if (
635
+ generation !== record.privateReconcileGeneration ||
636
+ this.binancePrivateReconcileIntervalMs === undefined ||
637
+ !this.isActive(record) ||
638
+ !this.hasPrivateReconcileCapability(record)
639
+ ) {
640
+ return;
641
+ }
642
+
643
+ let latestAccount: RegisteredAccountRecord;
644
+ try {
645
+ latestAccount = this.getAccount(record.accountId);
646
+ } catch (error) {
647
+ this.handlePrivateReconcileLookupError(record, error);
648
+ return;
649
+ }
650
+
651
+ record.privateReconcileInFlight = this.reconcilePrivateData(
652
+ record,
653
+ latestAccount,
654
+ generation,
655
+ true,
656
+ )
657
+ .catch(() => {})
658
+ .finally(() => {
659
+ if (generation !== record.privateReconcileGeneration) {
660
+ return;
661
+ }
662
+
663
+ record.privateReconcileInFlight = undefined;
664
+ if (
665
+ this.binancePrivateReconcileIntervalMs !== undefined &&
666
+ this.isActive(record) &&
667
+ this.hasPrivateReconcileCapability(record)
668
+ ) {
669
+ this.schedulePrivateReconcilePoll(record);
670
+ }
671
+ });
672
+ }, this.binancePrivateReconcileIntervalMs);
673
+ }
674
+
675
+ private handlePrivateReconcileLookupError(
676
+ record: PrivateSubscriptionRecord,
677
+ error: unknown,
678
+ ): void {
679
+ this.stopPrivateReconcilePolling(record);
680
+ if (error instanceof AcexError && error.code === "ACCOUNT_NOT_FOUND") {
681
+ return;
682
+ }
683
+
684
+ this.context.publishRuntimeError(
685
+ "adapter",
686
+ error instanceof Error
687
+ ? error
688
+ : new Error(`Failed to load ${record.venue} account for reconcile`),
689
+ {
690
+ accountId: record.accountId,
691
+ venue: record.venue,
692
+ },
693
+ );
694
+ }
695
+
362
696
  private handleAccountRefreshLookupError(
363
697
  record: PrivateSubscriptionRecord,
364
698
  error: unknown,
@@ -390,6 +724,7 @@ export class PrivateSubscriptionCoordinator {
390
724
  return;
391
725
  }
392
726
 
727
+ const requestStartedAt = this.context.now();
393
728
  try {
394
729
  const update = await adapter.refreshAccount(account.credentials ?? {}, {
395
730
  ...account.options,
@@ -407,7 +742,7 @@ export class PrivateSubscriptionCoordinator {
407
742
  record.accountId,
408
743
  record.venue,
409
744
  update,
410
- { preserveStatus: true },
745
+ { preserveStatus: true, requestStartedAt },
411
746
  );
412
747
  } catch (error) {
413
748
  if (
@@ -435,12 +770,337 @@ export class PrivateSubscriptionCoordinator {
435
770
  {
436
771
  runtimeStatus: "degraded",
437
772
  ready: record.accountReady,
438
- reason: "http_failed",
773
+ reason: transportReason(error, "http_failed"),
774
+ },
775
+ );
776
+ }
777
+ }
778
+
779
+ private async reconcilePrivateData(
780
+ record: PrivateSubscriptionRecord,
781
+ account: RegisteredAccountRecord,
782
+ generation: number,
783
+ preserveStatus: boolean,
784
+ ): Promise<void> {
785
+ const accountGeneration = record.accountSubscriptionGeneration;
786
+ const orderGeneration = record.orderSubscriptionGeneration;
787
+
788
+ await Promise.all([
789
+ this.reconcileAccount(
790
+ record,
791
+ account,
792
+ generation,
793
+ accountGeneration,
794
+ preserveStatus,
795
+ ),
796
+ this.reconcileOrders(
797
+ record,
798
+ account,
799
+ generation,
800
+ orderGeneration,
801
+ preserveStatus,
802
+ ),
803
+ ]);
804
+ }
805
+
806
+ private async reconcileAccount(
807
+ record: PrivateSubscriptionRecord,
808
+ account: RegisteredAccountRecord,
809
+ generation: number,
810
+ accountGeneration: number,
811
+ preserveStatus: boolean,
812
+ ): Promise<void> {
813
+ const adapter = this.getAdapter(record.venue);
814
+ if (
815
+ !this.shouldContinueAccountBootstrap(
816
+ record,
817
+ generation,
818
+ accountGeneration,
819
+ )
820
+ ) {
821
+ return;
822
+ }
823
+
824
+ const requestStartedAt = this.context.now();
825
+ try {
826
+ const snapshot = adapter.reconcileAccount
827
+ ? await adapter.reconcileAccount(account.credentials ?? {}, {
828
+ ...account.options,
829
+ accountId: account.accountId,
830
+ })
831
+ : await adapter.bootstrapAccount(account.credentials ?? {}, {
832
+ ...account.options,
833
+ accountId: account.accountId,
834
+ });
835
+ if (
836
+ !this.shouldContinueAccountBootstrap(
837
+ record,
838
+ generation,
839
+ accountGeneration,
840
+ )
841
+ ) {
842
+ return;
843
+ }
844
+
845
+ record.accountReady = true;
846
+ this.accountConsumer.onPrivateAccountReconcile(
847
+ record.accountId,
848
+ record.venue,
849
+ snapshot,
850
+ {
851
+ requestStartedAt,
852
+ preserveStatus,
853
+ },
854
+ );
855
+ } catch (error) {
856
+ if (
857
+ !this.shouldContinueAccountBootstrap(
858
+ record,
859
+ generation,
860
+ accountGeneration,
861
+ )
862
+ ) {
863
+ return;
864
+ }
865
+
866
+ this.context.publishRuntimeError(
867
+ "adapter",
868
+ error instanceof Error
869
+ ? error
870
+ : new Error(
871
+ `Failed to reconcile ${record.venue} private account state`,
872
+ ),
873
+ {
874
+ accountId: record.accountId,
875
+ venue: record.venue,
876
+ },
877
+ );
878
+ this.accountConsumer.onPrivateAccountStreamState(
879
+ record.accountId,
880
+ record.venue,
881
+ {
882
+ runtimeStatus: "degraded",
883
+ ready: record.accountReady,
884
+ reason: transportReason(error, "http_failed"),
885
+ },
886
+ );
887
+ }
888
+ }
889
+
890
+ private async reconcileOrders(
891
+ record: PrivateSubscriptionRecord,
892
+ account: RegisteredAccountRecord,
893
+ generation: number,
894
+ orderGeneration: number,
895
+ preserveStatus: boolean,
896
+ ): Promise<void> {
897
+ const adapter = this.getAdapter(record.venue);
898
+ if (
899
+ !adapter.fetchOpenOrders ||
900
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
901
+ ) {
902
+ return;
903
+ }
904
+
905
+ const requestStartedAt = this.context.now();
906
+ try {
907
+ const snapshot = await adapter.fetchOpenOrders(
908
+ account.credentials ?? {},
909
+ {
910
+ ...account.options,
911
+ accountId: account.accountId,
912
+ },
913
+ );
914
+ if (
915
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
916
+ ) {
917
+ return;
918
+ }
919
+
920
+ record.orderReady = true;
921
+ const disappeared = this.orderConsumer.onPrivateOrderReconcile(
922
+ record.accountId,
923
+ record.venue,
924
+ snapshot,
925
+ {
926
+ requestStartedAt,
927
+ preserveStatus,
928
+ },
929
+ );
930
+ await this.backfillDisappearedOrders(
931
+ record,
932
+ account,
933
+ generation,
934
+ orderGeneration,
935
+ disappeared,
936
+ );
937
+ } catch (error) {
938
+ if (
939
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
940
+ ) {
941
+ return;
942
+ }
943
+
944
+ this.handleOrderReconcileError(record, error);
945
+ }
946
+ }
947
+
948
+ private async backfillDisappearedOrders(
949
+ record: PrivateSubscriptionRecord,
950
+ account: RegisteredAccountRecord,
951
+ generation: number,
952
+ orderGeneration: number,
953
+ disappeared: OrderSnapshot[],
954
+ ): Promise<void> {
955
+ const adapter = this.getAdapter(record.venue);
956
+ if (!adapter.fetchOrder || disappeared.length === 0) {
957
+ return;
958
+ }
959
+
960
+ const pending = disappeared
961
+ .filter((order) => order.orderId || order.clientOrderId)
962
+ .slice(0, MAX_ORDER_TERMINAL_BACKFILLS_PER_RECONCILE);
963
+ if (pending.length === 0) {
964
+ return;
965
+ }
966
+
967
+ let cursor = 0;
968
+ const workers = Array.from(
969
+ {
970
+ length: Math.min(
971
+ MAX_ORDER_TERMINAL_BACKFILL_CONCURRENCY,
972
+ pending.length,
973
+ ),
974
+ },
975
+ async () => {
976
+ while (cursor < pending.length) {
977
+ if (
978
+ !this.shouldContinueOrderBootstrap(
979
+ record,
980
+ generation,
981
+ orderGeneration,
982
+ )
983
+ ) {
984
+ return;
985
+ }
986
+
987
+ const order = pending[cursor];
988
+ cursor += 1;
989
+ if (!order) {
990
+ continue;
991
+ }
992
+ await this.backfillDisappearedOrder(
993
+ record,
994
+ account,
995
+ generation,
996
+ orderGeneration,
997
+ order,
998
+ );
999
+ }
1000
+ },
1001
+ );
1002
+
1003
+ await Promise.all(workers);
1004
+ }
1005
+
1006
+ private async backfillDisappearedOrder(
1007
+ record: PrivateSubscriptionRecord,
1008
+ account: RegisteredAccountRecord,
1009
+ generation: number,
1010
+ orderGeneration: number,
1011
+ order: OrderSnapshot,
1012
+ ): Promise<void> {
1013
+ const adapter = this.getAdapter(record.venue);
1014
+ if (
1015
+ !adapter.fetchOrder ||
1016
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1017
+ ) {
1018
+ return;
1019
+ }
1020
+
1021
+ const requestStartedAt = this.context.now();
1022
+ try {
1023
+ let request: FetchOrderRequest;
1024
+ if (order.orderId) {
1025
+ request = {
1026
+ symbol: order.symbol,
1027
+ orderId: order.orderId,
1028
+ clientOrderId: order.clientOrderId,
1029
+ };
1030
+ } else if (order.clientOrderId) {
1031
+ request = {
1032
+ symbol: order.symbol,
1033
+ clientOrderId: order.clientOrderId,
1034
+ };
1035
+ } else {
1036
+ return;
1037
+ }
1038
+
1039
+ const update = await adapter.fetchOrder(
1040
+ account.credentials ?? {},
1041
+ request,
1042
+ { ...account.options, accountId: account.accountId },
1043
+ );
1044
+ if (
1045
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1046
+ ) {
1047
+ return;
1048
+ }
1049
+
1050
+ if (!update) {
1051
+ this.handleOrderReconcileError(
1052
+ record,
1053
+ new Error(
1054
+ `Failed to backfill disappeared ${record.venue} order terminal state`,
1055
+ ),
1056
+ );
1057
+ return;
1058
+ }
1059
+
1060
+ this.orderConsumer.onPrivateOrderUpdate(
1061
+ record.accountId,
1062
+ record.venue,
1063
+ update,
1064
+ {
1065
+ requestStartedAt,
439
1066
  },
440
1067
  );
1068
+ } catch (error) {
1069
+ if (
1070
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1071
+ ) {
1072
+ return;
1073
+ }
1074
+
1075
+ this.handleOrderReconcileError(record, error);
441
1076
  }
442
1077
  }
443
1078
 
1079
+ private handleOrderReconcileError(
1080
+ record: PrivateSubscriptionRecord,
1081
+ error: unknown,
1082
+ ): void {
1083
+ this.context.publishRuntimeError(
1084
+ "adapter",
1085
+ error instanceof Error
1086
+ ? error
1087
+ : new Error(`Failed to reconcile ${record.venue} private order state`),
1088
+ {
1089
+ accountId: record.accountId,
1090
+ venue: record.venue,
1091
+ },
1092
+ );
1093
+ this.orderConsumer.onPrivateOrderStreamState(
1094
+ record.accountId,
1095
+ record.venue,
1096
+ {
1097
+ runtimeStatus: "degraded",
1098
+ ready: record.orderReady,
1099
+ reason: transportReason(error, "http_failed"),
1100
+ },
1101
+ );
1102
+ }
1103
+
444
1104
  private async ensureStream(
445
1105
  record: PrivateSubscriptionRecord,
446
1106
  account: RegisteredAccountRecord,
@@ -466,15 +1126,21 @@ export class PrivateSubscriptionCoordinator {
466
1126
  record: PrivateSubscriptionRecord,
467
1127
  account: RegisteredAccountRecord,
468
1128
  ): Promise<void> {
1129
+ const adapter = this.getAdapter(record.venue);
469
1130
  const credentials = account.credentials;
470
- if (!credentials && record.venue !== "juplend") {
1131
+ if (adapter.accountCapabilities.credentialsRequired && !credentials) {
471
1132
  throw new AcexError(
472
1133
  "CREDENTIALS_MISSING",
473
1134
  `Account credentials are required for private subscriptions: ${account.accountId}`,
1135
+ {
1136
+ details: buildAcexErrorDetails({
1137
+ accountId: account.accountId,
1138
+ venue: account.venue,
1139
+ }),
1140
+ },
474
1141
  );
475
1142
  }
476
1143
 
477
- const adapter = this.getAdapter(record.venue);
478
1144
  const stream = adapter.createPrivateStream(
479
1145
  credentials ?? {},
480
1146
  {
@@ -559,7 +1225,7 @@ export class PrivateSubscriptionCoordinator {
559
1225
  {
560
1226
  runtimeStatus: "degraded",
561
1227
  ready: record.accountReady,
562
- reason: "http_failed",
1228
+ reason: transportReason(error, "http_failed"),
563
1229
  },
564
1230
  );
565
1231
  }
@@ -570,10 +1236,9 @@ export class PrivateSubscriptionCoordinator {
570
1236
  reconnectDelayMs: this.streamReconnectDelayMs,
571
1237
  reconnectMaxDelayMs: this.streamReconnectMaxDelayMs,
572
1238
  listenKeyKeepAliveMs: this.listenKeyKeepAliveMs,
573
- juplendPollIntervalMs: this.juplendPollIntervalMs,
574
1239
  now: () => this.context.now(),
575
1240
  },
576
- account.options,
1241
+ { ...account.options, accountId: account.accountId },
577
1242
  );
578
1243
 
579
1244
  record.stream = stream;
@@ -622,31 +1287,65 @@ export class PrivateSubscriptionCoordinator {
622
1287
  record: PrivateSubscriptionRecord,
623
1288
  ): Promise<void> {
624
1289
  const account = this.getAccount(record.accountId);
1290
+ const generation = record.privateReconcileGeneration;
1291
+ const accountGeneration = record.accountSubscriptionGeneration;
1292
+ const orderGeneration = record.orderSubscriptionGeneration;
625
1293
 
626
1294
  if (record.accountSubscribed) {
627
1295
  this.accountConsumer.onPrivateAccountPending(
628
1296
  record.accountId,
629
1297
  record.venue,
630
1298
  );
631
- await this.bootstrapAccount(record, account);
1299
+ await this.reconcileAccount(
1300
+ record,
1301
+ account,
1302
+ generation,
1303
+ accountGeneration,
1304
+ false,
1305
+ );
632
1306
  }
633
1307
 
634
1308
  if (record.ordersSubscribed) {
635
1309
  this.orderConsumer.onPrivateOrderPending(record.accountId, record.venue);
636
- await this.bootstrapOrders(record, account);
1310
+ await this.reconcileOrders(
1311
+ record,
1312
+ account,
1313
+ generation,
1314
+ orderGeneration,
1315
+ false,
1316
+ );
637
1317
  }
638
1318
  }
639
1319
 
640
1320
  private async bootstrapAccount(
641
1321
  record: PrivateSubscriptionRecord,
642
1322
  account: RegisteredAccountRecord,
1323
+ generation: number,
1324
+ accountGeneration: number,
643
1325
  ): Promise<void> {
1326
+ if (
1327
+ !this.shouldContinueAccountBootstrap(
1328
+ record,
1329
+ generation,
1330
+ accountGeneration,
1331
+ )
1332
+ ) {
1333
+ return;
1334
+ }
1335
+
644
1336
  try {
645
- const bootstrap = await this.getAdapter(record.venue).bootstrapAccount(
1337
+ const adapter = this.getAdapter(record.venue);
1338
+ const bootstrap = await adapter.bootstrapAccount(
646
1339
  account.credentials ?? {},
647
1340
  { ...account.options, accountId: account.accountId },
648
1341
  );
649
- if (!record.accountSubscribed) {
1342
+ if (
1343
+ !this.shouldContinueAccountBootstrap(
1344
+ record,
1345
+ generation,
1346
+ accountGeneration,
1347
+ )
1348
+ ) {
650
1349
  return;
651
1350
  }
652
1351
 
@@ -657,6 +1356,16 @@ export class PrivateSubscriptionCoordinator {
657
1356
  bootstrap,
658
1357
  );
659
1358
  } catch (error) {
1359
+ if (
1360
+ !this.shouldContinueAccountBootstrap(
1361
+ record,
1362
+ generation,
1363
+ accountGeneration,
1364
+ )
1365
+ ) {
1366
+ return;
1367
+ }
1368
+
660
1369
  record.accountReady = false;
661
1370
  this.context.publishRuntimeError(
662
1371
  "adapter",
@@ -676,14 +1385,32 @@ export class PrivateSubscriptionCoordinator {
676
1385
  {
677
1386
  runtimeStatus: "degraded",
678
1387
  ready: false,
679
- reason: record.venue === "juplend" ? "http_failed" : "auth_failed",
1388
+ reason: transportReason(
1389
+ error,
1390
+ this.getAdapter(record.venue).accountCapabilities
1391
+ .credentialsRequired
1392
+ ? "auth_failed"
1393
+ : "http_failed",
1394
+ ),
680
1395
  },
681
1396
  );
682
- const reason =
683
- error instanceof Error && error.message ? ` (${error.message})` : "";
1397
+ const details = buildAcexErrorDetails(
1398
+ {
1399
+ accountId: record.accountId,
1400
+ venue: record.venue,
1401
+ },
1402
+ error,
1403
+ );
684
1404
  throw new AcexError(
685
1405
  "ACCOUNT_BOOTSTRAP_FAILED",
686
- `Failed to bootstrap account data: ${record.accountId}${reason}`,
1406
+ formatAcexErrorMessage(
1407
+ `Failed to bootstrap account data: ${record.accountId}`,
1408
+ details,
1409
+ ),
1410
+ {
1411
+ cause: error,
1412
+ details,
1413
+ },
687
1414
  );
688
1415
  }
689
1416
  }
@@ -691,49 +1418,157 @@ export class PrivateSubscriptionCoordinator {
691
1418
  private async bootstrapOrders(
692
1419
  record: PrivateSubscriptionRecord,
693
1420
  account: RegisteredAccountRecord,
1421
+ generation: number,
1422
+ orderGeneration: number,
694
1423
  ): Promise<void> {
1424
+ if (
1425
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1426
+ ) {
1427
+ return;
1428
+ }
1429
+
1430
+ const adapter = this.getAdapter(record.venue);
1431
+ if (!adapter.fetchOpenOrders) {
1432
+ try {
1433
+ const requestStartedAt = this.context.now();
1434
+ const orders = await adapter.bootstrapOpenOrders(
1435
+ account.credentials ?? {},
1436
+ { ...account.options, accountId: account.accountId },
1437
+ );
1438
+ const snapshot: RawOpenOrdersSnapshot = {
1439
+ orders,
1440
+ snapshotReceivedAt:
1441
+ orders.reduce(
1442
+ (latest, order) => Math.max(latest, order.receivedAt),
1443
+ 0,
1444
+ ) || this.context.now(),
1445
+ };
1446
+ if (
1447
+ !this.shouldContinueOrderBootstrap(
1448
+ record,
1449
+ generation,
1450
+ orderGeneration,
1451
+ )
1452
+ ) {
1453
+ return;
1454
+ }
1455
+
1456
+ record.orderReady = true;
1457
+ const disappeared = this.orderConsumer.onPrivateOrderBootstrap(
1458
+ record.accountId,
1459
+ record.venue,
1460
+ snapshot,
1461
+ {
1462
+ requestStartedAt,
1463
+ },
1464
+ );
1465
+ await this.backfillDisappearedOrders(
1466
+ record,
1467
+ account,
1468
+ generation,
1469
+ orderGeneration,
1470
+ disappeared,
1471
+ );
1472
+ return;
1473
+ } catch (error) {
1474
+ if (
1475
+ !this.shouldContinueOrderBootstrap(
1476
+ record,
1477
+ generation,
1478
+ orderGeneration,
1479
+ )
1480
+ ) {
1481
+ return;
1482
+ }
1483
+
1484
+ this.handleBootstrapOrdersError(record, error);
1485
+ return;
1486
+ }
1487
+ }
1488
+
1489
+ const requestStartedAt = this.context.now();
695
1490
  try {
696
- const snapshots = await this.getAdapter(record.venue).bootstrapOpenOrders(
1491
+ const snapshot = await adapter.fetchOpenOrders(
697
1492
  account.credentials ?? {},
698
- account.options,
1493
+ {
1494
+ ...account.options,
1495
+ accountId: account.accountId,
1496
+ },
699
1497
  );
700
- if (!record.ordersSubscribed) {
1498
+ if (
1499
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1500
+ ) {
701
1501
  return;
702
1502
  }
703
1503
 
704
1504
  record.orderReady = true;
705
- this.orderConsumer.onPrivateOrderBootstrap(
1505
+ const disappeared = this.orderConsumer.onPrivateOrderBootstrap(
706
1506
  record.accountId,
707
1507
  record.venue,
708
- snapshots,
709
- );
710
- } catch (error) {
711
- record.orderReady = false;
712
- this.context.publishRuntimeError(
713
- "adapter",
714
- error instanceof Error
715
- ? error
716
- : new Error(
717
- `Failed to bootstrap ${record.venue} private order state`,
718
- ),
1508
+ snapshot,
719
1509
  {
720
- accountId: record.accountId,
721
- venue: record.venue,
1510
+ requestStartedAt,
722
1511
  },
723
1512
  );
724
- this.orderConsumer.onPrivateOrderStreamState(
725
- record.accountId,
726
- record.venue,
727
- {
728
- runtimeStatus: "degraded",
729
- ready: false,
730
- reason: "auth_failed",
731
- },
732
- );
733
- throw new AcexError(
734
- "ORDER_BOOTSTRAP_FAILED",
735
- `Failed to bootstrap order data: ${record.accountId}`,
1513
+ await this.backfillDisappearedOrders(
1514
+ record,
1515
+ account,
1516
+ generation,
1517
+ orderGeneration,
1518
+ disappeared,
736
1519
  );
1520
+ } catch (error) {
1521
+ if (
1522
+ !this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
1523
+ ) {
1524
+ return;
1525
+ }
1526
+
1527
+ this.handleBootstrapOrdersError(record, error);
737
1528
  }
738
1529
  }
1530
+
1531
+ private handleBootstrapOrdersError(
1532
+ record: PrivateSubscriptionRecord,
1533
+ error: unknown,
1534
+ ): never {
1535
+ record.orderReady = false;
1536
+ this.context.publishRuntimeError(
1537
+ "adapter",
1538
+ error instanceof Error
1539
+ ? error
1540
+ : new Error(`Failed to bootstrap ${record.venue} private order state`),
1541
+ {
1542
+ accountId: record.accountId,
1543
+ venue: record.venue,
1544
+ },
1545
+ );
1546
+ this.orderConsumer.onPrivateOrderStreamState(
1547
+ record.accountId,
1548
+ record.venue,
1549
+ {
1550
+ runtimeStatus: "degraded",
1551
+ ready: false,
1552
+ reason: transportReason(error, "auth_failed"),
1553
+ },
1554
+ );
1555
+ const details = buildAcexErrorDetails(
1556
+ {
1557
+ accountId: record.accountId,
1558
+ venue: record.venue,
1559
+ },
1560
+ error,
1561
+ );
1562
+ throw new AcexError(
1563
+ "ORDER_BOOTSTRAP_FAILED",
1564
+ formatAcexErrorMessage(
1565
+ `Failed to bootstrap order data: ${record.accountId}`,
1566
+ details,
1567
+ ),
1568
+ {
1569
+ cause: error,
1570
+ details,
1571
+ },
1572
+ );
1573
+ }
739
1574
  }