@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.
- package/README.md +11 -10
- package/docs/api.md +502 -1030
- package/package.json +1 -1
- package/src/adapters/binance/adapter.ts +19 -1
- package/src/adapters/binance/market-catalog.ts +93 -22
- package/src/adapters/binance/private-adapter.ts +302 -59
- package/src/adapters/binance/rate-limit.ts +47 -0
- package/src/adapters/binance/server-time.ts +106 -0
- package/src/adapters/juplend/private-adapter.ts +97 -68
- package/src/adapters/types.ts +25 -1
- package/src/client/context.ts +26 -9
- package/src/client/private-subscription-coordinator.ts +898 -63
- package/src/client/runtime.ts +49 -11
- package/src/client/venue-capabilities.ts +1 -0
- package/src/errors.ts +156 -2
- package/src/index.ts +8 -1
- package/src/internal/decimal.ts +19 -0
- package/src/internal/http-client.ts +608 -0
- package/src/internal/rate-limiter.ts +181 -0
- package/src/internal/watermark.ts +83 -0
- package/src/managers/account-manager.ts +267 -55
- package/src/managers/market-manager.ts +261 -60
- package/src/managers/order-manager.ts +798 -84
- package/src/types/account.ts +27 -28
- package/src/types/client.ts +1 -0
- package/src/types/market.ts +37 -12
- package/src/types/order.ts +7 -7
- package/src/types/shared.ts +66 -0
|
@@ -14,14 +14,20 @@ import type {
|
|
|
14
14
|
HealthReporter,
|
|
15
15
|
ManagerLifecycle,
|
|
16
16
|
} from "../client/context.ts";
|
|
17
|
-
import {
|
|
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<
|
|
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
|
|
266
|
-
const
|
|
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:
|
|
270
|
-
amount:
|
|
271
|
-
rawPrice:
|
|
272
|
-
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
|
|
276
|
-
amountStep: market.amountStep
|
|
277
|
-
minAmount: market.minAmount
|
|
278
|
-
minNotional: market.minNotional
|
|
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 (
|
|
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 (
|
|
376
|
+
if (minNotional) {
|
|
306
377
|
const notional = amount.multipliedBy(price);
|
|
307
|
-
if (notional.isLessThan(
|
|
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
|
-
|
|
429
|
-
|
|
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
|
|
504
|
+
const result = await this.fetchCatalogCoalesced(venue);
|
|
505
|
+
return { ...result, ok: true };
|
|
436
506
|
} catch (error) {
|
|
437
|
-
|
|
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
|
|
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
|
-
|
|
546
|
+
markets = await adapter.loadMarkets();
|
|
547
|
+
} catch (error) {
|
|
548
|
+
throw this.createCatalogLoadError(venue, error);
|
|
549
|
+
}
|
|
447
550
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
error
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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:
|
|
778
|
-
bidSize:
|
|
779
|
-
askPrice:
|
|
780
|
-
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:
|
|
1005
|
+
fundingRate: toCanonical(input.fundingRate),
|
|
805
1006
|
nextFundingTime: input.nextFundingTime,
|
|
806
|
-
markPrice: input.markPrice ?
|
|
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
|
}
|