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

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.
@@ -14,7 +14,11 @@ import type {
14
14
  HealthReporter,
15
15
  ManagerLifecycle,
16
16
  } from "../client/context.ts";
17
- import { AcexError } from "../errors.ts";
17
+ import {
18
+ AcexError,
19
+ buildAcexErrorDetails,
20
+ formatAcexErrorMessage,
21
+ } from "../errors.ts";
18
22
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
19
23
  import { toCanonical } from "../internal/decimal.ts";
20
24
  import { matchesMarketFilter } from "../internal/filters.ts";
@@ -23,6 +27,7 @@ import type {
23
27
  FundingRateUpdatedEvent,
24
28
  L1Book,
25
29
  L1BookUpdatedEvent,
30
+ MarketCatalogReloadSummary,
26
31
  MarketDataStatus,
27
32
  MarketDataStreamStatus,
28
33
  MarketDefinition,
@@ -37,6 +42,7 @@ import type {
37
42
  SubscribeL1BookInput,
38
43
  SubscriptionActivity,
39
44
  Venue,
45
+ VenueServerTime,
40
46
  } from "../types/index.ts";
41
47
 
42
48
  export interface MarketManagerOptions {
@@ -63,6 +69,13 @@ interface MarketRecord {
63
69
  fundingRateStream?: StreamHandle;
64
70
  }
65
71
 
72
+ interface CatalogFetchResult {
73
+ venue: Venue;
74
+ added: string[];
75
+ removed: string[];
76
+ total: number;
77
+ }
78
+
66
79
  const DEFAULT_INITIAL_L1_TIMEOUT_MS = 15_000;
67
80
  const DEFAULT_L1_STALE_AFTER_MS = 15_000;
68
81
  const DEFAULT_L1_RECONNECT_DELAY_MS = 1_000;
@@ -114,7 +127,10 @@ export class MarketManagerImpl
114
127
  private readonly definitions = new Map<string, MarketDefinition>();
115
128
  private readonly records = new Map<string, MarketRecord>();
116
129
  private readonly loadedCatalogVenues = new Set<Venue>();
117
- private readonly catalogPromises = new Map<Venue, Promise<void>>();
130
+ private readonly catalogPromises = new Map<
131
+ Venue,
132
+ Promise<CatalogFetchResult>
133
+ >();
118
134
  private readonly initialL1TimeoutMs: number;
119
135
  private readonly l1StaleAfterMs: number;
120
136
  private readonly l1ReconnectDelayMs: number;
@@ -166,6 +182,48 @@ export class MarketManagerImpl
166
182
  );
167
183
  }
168
184
 
185
+ async reloadMarkets(venue?: Venue): Promise<MarketCatalogReloadSummary[]> {
186
+ if (venue !== undefined) {
187
+ this.assertSupportedVenue(venue);
188
+ return [await this.reloadVenue(venue)];
189
+ }
190
+
191
+ const venues = [...this.adapters.keys()];
192
+ const settled = await Promise.allSettled(
193
+ venues.map((registeredVenue) => this.reloadVenue(registeredVenue)),
194
+ );
195
+ const summaries: MarketCatalogReloadSummary[] = [];
196
+
197
+ for (const result of settled) {
198
+ if (result.status === "fulfilled") {
199
+ summaries.push(result.value);
200
+ continue;
201
+ }
202
+
203
+ throw result.reason;
204
+ }
205
+
206
+ return summaries;
207
+ }
208
+
209
+ async fetchServerTime(venue: Venue): Promise<VenueServerTime> {
210
+ const adapter = this.getMarketAdapter(venue);
211
+ if (!adapter.fetchServerTime) {
212
+ throw this.createError(
213
+ "VENUE_NOT_SUPPORTED",
214
+ `Venue is not supported yet: ${venue}`,
215
+ { venue },
216
+ "client",
217
+ );
218
+ }
219
+
220
+ try {
221
+ return await adapter.fetchServerTime();
222
+ } catch (error) {
223
+ throw this.createServerTimeFetchError(venue, error);
224
+ }
225
+ }
226
+
169
227
  async subscribeL1Book(input: SubscribeL1BookInput): Promise<void> {
170
228
  this.context.assertStarted();
171
229
  const market = await this.resolveMarketDefinition(input);
@@ -438,51 +496,163 @@ export class MarketManagerImpl
438
496
  return;
439
497
  }
440
498
 
441
- let catalogPromise = this.catalogPromises.get(venue);
442
- if (!catalogPromise) {
443
- catalogPromise = this.fetchAndStoreMarketCatalog(venue);
444
- this.catalogPromises.set(venue, catalogPromise);
445
- }
499
+ await this.fetchCatalogCoalesced(venue);
500
+ }
446
501
 
502
+ private async reloadVenue(venue: Venue): Promise<MarketCatalogReloadSummary> {
447
503
  try {
448
- await catalogPromise;
504
+ const result = await this.fetchCatalogCoalesced(venue);
505
+ return { ...result, ok: true };
449
506
  } catch (error) {
450
- this.catalogPromises.delete(venue);
507
+ if (
508
+ error instanceof AcexError &&
509
+ error.code === "MARKET_CATALOG_LOAD_FAILED"
510
+ ) {
511
+ return {
512
+ venue,
513
+ added: [],
514
+ removed: [],
515
+ total: this.countVenueMarkets(venue),
516
+ ok: false,
517
+ error,
518
+ };
519
+ }
520
+
451
521
  throw error;
452
522
  }
453
523
  }
454
524
 
455
- private async fetchAndStoreMarketCatalog(venue: Venue): Promise<void> {
525
+ private async fetchCatalogCoalesced(
526
+ venue: Venue,
527
+ ): Promise<CatalogFetchResult> {
528
+ let catalogPromise = this.catalogPromises.get(venue);
529
+ if (!catalogPromise) {
530
+ catalogPromise = this.fetchAndStoreMarketCatalog(venue).finally(() => {
531
+ this.catalogPromises.delete(venue);
532
+ });
533
+ this.catalogPromises.set(venue, catalogPromise);
534
+ }
535
+
536
+ return await catalogPromise;
537
+ }
538
+
539
+ private async fetchAndStoreMarketCatalog(
540
+ venue: Venue,
541
+ ): Promise<CatalogFetchResult> {
456
542
  const adapter = this.getMarketAdapter(venue);
543
+ let markets: MarketDefinition[];
457
544
 
458
545
  try {
459
- const markets = await adapter.loadMarkets();
546
+ markets = await adapter.loadMarkets();
547
+ } catch (error) {
548
+ throw this.createCatalogLoadError(venue, error);
549
+ }
460
550
 
461
- for (const [key, market] of this.definitions) {
462
- if (market.venue === venue) {
463
- this.definitions.delete(key);
464
- }
551
+ const mismatchedMarket = markets.find((market) => market.venue !== venue);
552
+ if (mismatchedMarket) {
553
+ throw this.createCatalogLoadError(
554
+ venue,
555
+ new Error(
556
+ `Market catalog from ${venue} included ${mismatchedMarket.venue} market: ${mismatchedMarket.symbol}`,
557
+ ),
558
+ );
559
+ }
560
+
561
+ const previousKeys = this.getVenueMarketKeys(venue);
562
+
563
+ for (const [key, market] of this.definitions) {
564
+ if (market.venue === venue) {
565
+ this.definitions.delete(key);
465
566
  }
567
+ }
568
+
569
+ for (const market of markets) {
570
+ this.definitions.set(marketKey(market), market);
571
+ }
572
+
573
+ this.loadedCatalogVenues.add(venue);
466
574
 
467
- for (const market of markets) {
468
- this.definitions.set(marketKey(market), market);
575
+ const currentKeys = this.getVenueMarketKeys(venue);
576
+ return {
577
+ venue,
578
+ added: this.diffMarketSymbols(venue, currentKeys, previousKeys),
579
+ removed: this.diffMarketSymbols(venue, previousKeys, currentKeys),
580
+ total: currentKeys.size,
581
+ };
582
+ }
583
+
584
+ private getVenueMarketKeys(venue: Venue): Set<string> {
585
+ const keys = new Set<string>();
586
+
587
+ for (const [key, market] of this.definitions) {
588
+ if (market.venue === venue) {
589
+ keys.add(key);
469
590
  }
591
+ }
470
592
 
471
- this.loadedCatalogVenues.add(venue);
472
- } catch (error) {
473
- const wrapped = new AcexError(
474
- "MARKET_CATALOG_LOAD_FAILED",
593
+ return keys;
594
+ }
595
+
596
+ private countVenueMarkets(venue: Venue): number {
597
+ return this.getVenueMarketKeys(venue).size;
598
+ }
599
+
600
+ private diffMarketSymbols(
601
+ venue: Venue,
602
+ left: Set<string>,
603
+ right: Set<string>,
604
+ ): string[] {
605
+ const prefix = `${venue}:`;
606
+ return [...left]
607
+ .filter((key) => !right.has(key))
608
+ .map((key) => key.slice(prefix.length))
609
+ .sort((leftSymbol, rightSymbol) => leftSymbol.localeCompare(rightSymbol));
610
+ }
611
+
612
+ private createCatalogLoadError(venue: Venue, error: unknown): AcexError {
613
+ const details = buildAcexErrorDetails({ venue }, error);
614
+ const wrapped = new AcexError(
615
+ "MARKET_CATALOG_LOAD_FAILED",
616
+ formatAcexErrorMessage(
475
617
  `Failed to load market catalog from ${venue}`,
476
- );
477
- this.context.publishRuntimeError(
478
- "adapter",
479
- error instanceof Error
480
- ? error
481
- : new Error("Unknown catalog load failure"),
482
- { venue },
483
- );
484
- throw wrapped;
485
- }
618
+ details,
619
+ ),
620
+ {
621
+ cause: error,
622
+ details,
623
+ },
624
+ );
625
+ this.context.publishRuntimeError(
626
+ "adapter",
627
+ error instanceof Error
628
+ ? error
629
+ : new Error("Unknown catalog load failure"),
630
+ { venue },
631
+ );
632
+ return wrapped;
633
+ }
634
+
635
+ private createServerTimeFetchError(venue: Venue, error: unknown): AcexError {
636
+ const details = buildAcexErrorDetails({ venue }, error);
637
+ const wrapped = new AcexError(
638
+ "MARKET_SERVER_TIME_FETCH_FAILED",
639
+ formatAcexErrorMessage(
640
+ `Failed to fetch server time from ${venue}`,
641
+ details,
642
+ ),
643
+ {
644
+ cause: error,
645
+ details,
646
+ },
647
+ );
648
+ this.context.publishRuntimeError(
649
+ "adapter",
650
+ error instanceof Error
651
+ ? error
652
+ : new Error("Unknown server time fetch failure"),
653
+ { venue },
654
+ );
655
+ return wrapped;
486
656
  }
487
657
 
488
658
  private async resolveMarketDefinition(input: {
@@ -608,11 +778,20 @@ export class MarketManagerImpl
608
778
 
609
779
  try {
610
780
  await record.l1BookStream.ready;
611
- } catch {
781
+ } catch (error) {
782
+ record.l1BookStream.close();
612
783
  record.l1BookStream = undefined;
784
+ const details = buildAcexErrorDetails(
785
+ { venue: market.venue, symbol: market.symbol },
786
+ error,
787
+ );
613
788
  const timeoutError = new AcexError(
614
789
  "MARKET_STREAM_TIMEOUT",
615
790
  `Timed out waiting for market data: ${market.symbol}`,
791
+ {
792
+ cause: error,
793
+ details,
794
+ },
616
795
  );
617
796
  this.context.publishRuntimeError("runtime", timeoutError, {
618
797
  venue: market.venue,
@@ -636,11 +815,20 @@ export class MarketManagerImpl
636
815
 
637
816
  try {
638
817
  await record.fundingRateStream.ready;
639
- } catch {
818
+ } catch (error) {
819
+ record.fundingRateStream.close();
640
820
  record.fundingRateStream = undefined;
821
+ const details = buildAcexErrorDetails(
822
+ { venue: market.venue, symbol: market.symbol },
823
+ error,
824
+ );
641
825
  const timeoutError = new AcexError(
642
826
  "MARKET_STREAM_TIMEOUT",
643
827
  `Timed out waiting for market data: ${market.symbol}`,
828
+ {
829
+ cause: error,
830
+ details,
831
+ },
644
832
  );
645
833
  this.context.publishRuntimeError("runtime", timeoutError, {
646
834
  venue: market.venue,
@@ -1039,7 +1227,9 @@ export class MarketManagerImpl
1039
1227
  metadata?: { venue?: Venue; symbol?: string },
1040
1228
  source: "market" | "client" = "market",
1041
1229
  ): AcexError {
1042
- const error = new AcexError(code, message);
1230
+ const error = new AcexError(code, message, {
1231
+ details: buildAcexErrorDetails(metadata),
1232
+ });
1043
1233
  this.context.publishRuntimeError(source, error, metadata);
1044
1234
  return error;
1045
1235
  }