@imbingox/acex 0.3.1-beta.0 → 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.
@@ -14,14 +14,20 @@ 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";
23
+ import { toCanonical } from "../internal/decimal.ts";
19
24
  import { matchesMarketFilter } from "../internal/filters.ts";
20
25
  import type {
21
26
  FundingRateSnapshot,
22
27
  FundingRateUpdatedEvent,
23
28
  L1Book,
24
29
  L1BookUpdatedEvent,
30
+ MarketCatalogReloadSummary,
25
31
  MarketDataStatus,
26
32
  MarketDataStreamStatus,
27
33
  MarketDefinition,
@@ -36,6 +42,7 @@ import type {
36
42
  SubscribeL1BookInput,
37
43
  SubscriptionActivity,
38
44
  Venue,
45
+ VenueServerTime,
39
46
  } from "../types/index.ts";
40
47
 
41
48
  export interface MarketManagerOptions {
@@ -62,6 +69,13 @@ interface MarketRecord {
62
69
  fundingRateStream?: StreamHandle;
63
70
  }
64
71
 
72
+ interface CatalogFetchResult {
73
+ venue: Venue;
74
+ added: string[];
75
+ removed: string[];
76
+ total: number;
77
+ }
78
+
65
79
  const DEFAULT_INITIAL_L1_TIMEOUT_MS = 15_000;
66
80
  const DEFAULT_L1_STALE_AFTER_MS = 15_000;
67
81
  const DEFAULT_L1_RECONNECT_DELAY_MS = 1_000;
@@ -100,10 +114,6 @@ function floorToStep(value: BigNumber, step: BigNumber): BigNumber {
100
114
  return value.dividedToIntegerBy(step).multipliedBy(step);
101
115
  }
102
116
 
103
- function normalizeDecimalInput(value: BigNumber): string {
104
- return value.isFinite() ? value.toFixed() : value.toString();
105
- }
106
-
107
117
  export class MarketManagerImpl
108
118
  implements MarketManager, ManagerLifecycle, HealthReporter<MarketDataStatus>
109
119
  {
@@ -117,7 +127,10 @@ export class MarketManagerImpl
117
127
  private readonly definitions = new Map<string, MarketDefinition>();
118
128
  private readonly records = new Map<string, MarketRecord>();
119
129
  private readonly loadedCatalogVenues = new Set<Venue>();
120
- private readonly catalogPromises = new Map<Venue, Promise<void>>();
130
+ private readonly catalogPromises = new Map<
131
+ Venue,
132
+ Promise<CatalogFetchResult>
133
+ >();
121
134
  private readonly initialL1TimeoutMs: number;
122
135
  private readonly l1StaleAfterMs: number;
123
136
  private readonly l1ReconnectDelayMs: number;
@@ -169,6 +182,48 @@ export class MarketManagerImpl
169
182
  );
170
183
  }
171
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
+
172
227
  async subscribeL1Book(input: SubscribeL1BookInput): Promise<void> {
173
228
  this.context.assertStarted();
174
229
  const market = await this.resolveMarketDefinition(input);
@@ -262,20 +317,36 @@ export class MarketManagerImpl
262
317
  const market = this.resolveLoadedMarket(input);
263
318
  const rawPrice = new BigNumber(input.price);
264
319
  const rawAmount = new BigNumber(input.amount);
265
- const price = floorToStep(rawPrice, market.priceStep);
266
- const amount = floorToStep(rawAmount, market.amountStep);
320
+ const priceStep = new BigNumber(market.priceStep);
321
+ const amountStep = new BigNumber(market.amountStep);
322
+ const minAmount =
323
+ market.minAmount === undefined
324
+ ? undefined
325
+ : new BigNumber(market.minAmount);
326
+ const minNotional =
327
+ market.minNotional === undefined
328
+ ? undefined
329
+ : new BigNumber(market.minNotional);
330
+ const price = floorToStep(rawPrice, priceStep);
331
+ const amount = floorToStep(rawAmount, amountStep);
332
+
333
+ // normalizeOrderInput rejects non-finite input gracefully (see the
334
+ // isFinite checks below), so its echoed numeric fields fall back to the
335
+ // raw string instead of throwing the way toCanonical now does.
336
+ const echoDecimal = (value: BigNumber): string =>
337
+ value.isFinite() ? toCanonical(value) : value.toString();
267
338
 
268
339
  const normalized: NormalizedOrderInput = {
269
- price: normalizeDecimalInput(price),
270
- amount: normalizeDecimalInput(amount),
271
- rawPrice: normalizeDecimalInput(rawPrice),
272
- rawAmount: normalizeDecimalInput(rawAmount),
340
+ price: echoDecimal(price),
341
+ amount: echoDecimal(amount),
342
+ rawPrice: echoDecimal(rawPrice),
343
+ rawAmount: echoDecimal(rawAmount),
273
344
  adjusted: !price.isEqualTo(rawPrice) || !amount.isEqualTo(rawAmount),
274
345
  accepted: true,
275
- priceStep: market.priceStep.toFixed(),
276
- amountStep: market.amountStep.toFixed(),
277
- minAmount: market.minAmount?.toFixed(),
278
- minNotional: market.minNotional?.toFixed(),
346
+ priceStep: market.priceStep,
347
+ amountStep: market.amountStep,
348
+ minAmount: market.minAmount,
349
+ minNotional: market.minNotional,
279
350
  };
280
351
 
281
352
  if (!price.isFinite() || price.isLessThanOrEqualTo(0)) {
@@ -294,7 +365,7 @@ export class MarketManagerImpl
294
365
  };
295
366
  }
296
367
 
297
- if (market.minAmount && amount.isLessThan(market.minAmount)) {
368
+ if (minAmount && amount.isLessThan(minAmount)) {
298
369
  return {
299
370
  ...normalized,
300
371
  accepted: false,
@@ -302,9 +373,9 @@ export class MarketManagerImpl
302
373
  };
303
374
  }
304
375
 
305
- if (market.minNotional) {
376
+ if (minNotional) {
306
377
  const notional = amount.multipliedBy(price);
307
- if (notional.isLessThan(market.minNotional)) {
378
+ if (notional.isLessThan(minNotional)) {
308
379
  return {
309
380
  ...normalized,
310
381
  accepted: false,
@@ -425,51 +496,163 @@ export class MarketManagerImpl
425
496
  return;
426
497
  }
427
498
 
428
- let catalogPromise = this.catalogPromises.get(venue);
429
- if (!catalogPromise) {
430
- catalogPromise = this.fetchAndStoreMarketCatalog(venue);
431
- this.catalogPromises.set(venue, catalogPromise);
432
- }
499
+ await this.fetchCatalogCoalesced(venue);
500
+ }
433
501
 
502
+ private async reloadVenue(venue: Venue): Promise<MarketCatalogReloadSummary> {
434
503
  try {
435
- await catalogPromise;
504
+ const result = await this.fetchCatalogCoalesced(venue);
505
+ return { ...result, ok: true };
436
506
  } catch (error) {
437
- 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
+
438
521
  throw error;
439
522
  }
440
523
  }
441
524
 
442
- 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> {
443
542
  const adapter = this.getMarketAdapter(venue);
543
+ let markets: MarketDefinition[];
444
544
 
445
545
  try {
446
- const markets = await adapter.loadMarkets();
546
+ markets = await adapter.loadMarkets();
547
+ } catch (error) {
548
+ throw this.createCatalogLoadError(venue, error);
549
+ }
447
550
 
448
- for (const [key, market] of this.definitions) {
449
- if (market.venue === venue) {
450
- this.definitions.delete(key);
451
- }
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);
452
566
  }
567
+ }
568
+
569
+ for (const market of markets) {
570
+ this.definitions.set(marketKey(market), market);
571
+ }
453
572
 
454
- for (const market of markets) {
455
- this.definitions.set(marketKey(market), market);
573
+ this.loadedCatalogVenues.add(venue);
574
+
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);
456
590
  }
591
+ }
457
592
 
458
- this.loadedCatalogVenues.add(venue);
459
- } catch (error) {
460
- const wrapped = new AcexError(
461
- "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(
462
617
  `Failed to load market catalog from ${venue}`,
463
- );
464
- this.context.publishRuntimeError(
465
- "adapter",
466
- error instanceof Error
467
- ? error
468
- : new Error("Unknown catalog load failure"),
469
- { venue },
470
- );
471
- throw wrapped;
472
- }
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;
473
656
  }
474
657
 
475
658
  private async resolveMarketDefinition(input: {
@@ -595,11 +778,20 @@ export class MarketManagerImpl
595
778
 
596
779
  try {
597
780
  await record.l1BookStream.ready;
598
- } catch {
781
+ } catch (error) {
782
+ record.l1BookStream.close();
599
783
  record.l1BookStream = undefined;
784
+ const details = buildAcexErrorDetails(
785
+ { venue: market.venue, symbol: market.symbol },
786
+ error,
787
+ );
600
788
  const timeoutError = new AcexError(
601
789
  "MARKET_STREAM_TIMEOUT",
602
790
  `Timed out waiting for market data: ${market.symbol}`,
791
+ {
792
+ cause: error,
793
+ details,
794
+ },
603
795
  );
604
796
  this.context.publishRuntimeError("runtime", timeoutError, {
605
797
  venue: market.venue,
@@ -623,11 +815,20 @@ export class MarketManagerImpl
623
815
 
624
816
  try {
625
817
  await record.fundingRateStream.ready;
626
- } catch {
818
+ } catch (error) {
819
+ record.fundingRateStream.close();
627
820
  record.fundingRateStream = undefined;
821
+ const details = buildAcexErrorDetails(
822
+ { venue: market.venue, symbol: market.symbol },
823
+ error,
824
+ );
628
825
  const timeoutError = new AcexError(
629
826
  "MARKET_STREAM_TIMEOUT",
630
827
  `Timed out waiting for market data: ${market.symbol}`,
828
+ {
829
+ cause: error,
830
+ details,
831
+ },
631
832
  );
632
833
  this.context.publishRuntimeError("runtime", timeoutError, {
633
834
  venue: market.venue,
@@ -774,10 +975,10 @@ export class MarketManagerImpl
774
975
  return {
775
976
  venue,
776
977
  symbol,
777
- bidPrice: new BigNumber(input.bidPrice),
778
- bidSize: new BigNumber(input.bidSize),
779
- askPrice: new BigNumber(input.askPrice),
780
- askSize: new BigNumber(input.askSize),
978
+ bidPrice: toCanonical(input.bidPrice),
979
+ bidSize: toCanonical(input.bidSize),
980
+ askPrice: toCanonical(input.askPrice),
981
+ askSize: toCanonical(input.askSize),
781
982
  exchangeTs: input.exchangeTs,
782
983
  receivedAt: input.receivedAt,
783
984
  updatedAt: input.receivedAt,
@@ -801,12 +1002,10 @@ export class MarketManagerImpl
801
1002
  return {
802
1003
  venue,
803
1004
  symbol,
804
- fundingRate: new BigNumber(input.fundingRate),
1005
+ fundingRate: toCanonical(input.fundingRate),
805
1006
  nextFundingTime: input.nextFundingTime,
806
- markPrice: input.markPrice ? new BigNumber(input.markPrice) : undefined,
807
- indexPrice: input.indexPrice
808
- ? new BigNumber(input.indexPrice)
809
- : undefined,
1007
+ markPrice: input.markPrice ? toCanonical(input.markPrice) : undefined,
1008
+ indexPrice: input.indexPrice ? toCanonical(input.indexPrice) : undefined,
810
1009
  exchangeTs: input.exchangeTs,
811
1010
  receivedAt: input.receivedAt,
812
1011
  updatedAt: input.receivedAt,
@@ -1028,7 +1227,9 @@ export class MarketManagerImpl
1028
1227
  metadata?: { venue?: Venue; symbol?: string },
1029
1228
  source: "market" | "client" = "market",
1030
1229
  ): AcexError {
1031
- const error = new AcexError(code, message);
1230
+ const error = new AcexError(code, message, {
1231
+ details: buildAcexErrorDetails(metadata),
1232
+ });
1032
1233
  this.context.publishRuntimeError(source, error, metadata);
1033
1234
  return error;
1034
1235
  }