@imbingox/acex 0.1.0 → 0.2.0

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.
Files changed (85) hide show
  1. package/README.md +92 -285
  2. package/index.ts +1 -0
  3. package/package.json +40 -23
  4. package/src/adapters/binance/adapter.ts +80 -0
  5. package/src/adapters/binance/book-ticker.ts +123 -0
  6. package/src/adapters/binance/mark-price.ts +126 -0
  7. package/src/adapters/binance/market-catalog.ts +258 -0
  8. package/src/adapters/binance/private-adapter.ts +833 -0
  9. package/src/adapters/types.ts +219 -0
  10. package/src/client/context.ts +123 -0
  11. package/src/client/create-client.ts +6 -0
  12. package/src/client/private-subscription-coordinator.ts +512 -0
  13. package/src/client/runtime.ts +410 -0
  14. package/src/errors.ts +27 -0
  15. package/src/index.ts +5 -0
  16. package/src/internal/async-event-bus.ts +100 -0
  17. package/src/internal/filters.ts +117 -0
  18. package/src/internal/managed-websocket.ts +280 -0
  19. package/src/managers/account-manager.ts +609 -0
  20. package/src/managers/market-manager.ts +889 -0
  21. package/src/managers/order-manager.ts +685 -0
  22. package/src/types/account.ts +157 -0
  23. package/src/types/client.ts +79 -0
  24. package/src/types/index.ts +5 -0
  25. package/src/types/market.ts +150 -0
  26. package/src/types/order.ts +177 -0
  27. package/src/types/shared.ts +93 -0
  28. package/dist/adapters/binance/composite-adapter.d.ts +0 -116
  29. package/dist/adapters/binance/composite-adapter.js +0 -121
  30. package/dist/adapters/binance/market-types.d.ts +0 -63
  31. package/dist/adapters/binance/market-types.js +0 -1
  32. package/dist/adapters/binance/native-market-adapter.d.ts +0 -102
  33. package/dist/adapters/binance/native-market-adapter.js +0 -455
  34. package/dist/adapters/binance/normalizers.d.ts +0 -8
  35. package/dist/adapters/binance/normalizers.js +0 -123
  36. package/dist/adapters/binance/rest-client.d.ts +0 -17
  37. package/dist/adapters/binance/rest-client.js +0 -66
  38. package/dist/adapters/binance/symbol-router.d.ts +0 -9
  39. package/dist/adapters/binance/symbol-router.js +0 -174
  40. package/dist/adapters/binance/ws-client.d.ts +0 -24
  41. package/dist/adapters/binance/ws-client.js +0 -261
  42. package/dist/adapters/ccxt/aster-ccxt-adapter.d.ts +0 -157
  43. package/dist/adapters/ccxt/aster-ccxt-adapter.js +0 -272
  44. package/dist/adapters/ccxt/binance-usdm-ccxt-adapter.d.ts +0 -180
  45. package/dist/adapters/ccxt/binance-usdm-ccxt-adapter.js +0 -539
  46. package/dist/adapters/ccxt/binance-usdm-exchange.d.ts +0 -22
  47. package/dist/adapters/ccxt/binance-usdm-exchange.js +0 -23
  48. package/dist/adapters/fake/fake-aster-adapter.d.ts +0 -130
  49. package/dist/adapters/fake/fake-aster-adapter.js +0 -283
  50. package/dist/adapters/types.d.ts +0 -210
  51. package/dist/adapters/types.js +0 -1
  52. package/dist/core/client.d.ts +0 -50
  53. package/dist/core/client.js +0 -403
  54. package/dist/core/recovery.d.ts +0 -22
  55. package/dist/core/recovery.js +0 -18
  56. package/dist/core/runtime.d.ts +0 -26
  57. package/dist/core/runtime.js +0 -150
  58. package/dist/errors/acex-error.d.ts +0 -25
  59. package/dist/errors/acex-error.js +0 -54
  60. package/dist/index.d.ts +0 -6
  61. package/dist/index.js +0 -3
  62. package/dist/managers/account-manager.d.ts +0 -41
  63. package/dist/managers/account-manager.js +0 -80
  64. package/dist/managers/market-manager.d.ts +0 -16
  65. package/dist/managers/market-manager.js +0 -28
  66. package/dist/managers/order-manager.d.ts +0 -87
  67. package/dist/managers/order-manager.js +0 -122
  68. package/dist/runtime/async-queue.d.ts +0 -8
  69. package/dist/runtime/async-queue.js +0 -88
  70. package/dist/runtime/request-id.d.ts +0 -1
  71. package/dist/runtime/request-id.js +0 -5
  72. package/dist/runtime/ws-connection-supervisor.d.ts +0 -76
  73. package/dist/runtime/ws-connection-supervisor.js +0 -522
  74. package/dist/store/account-store.d.ts +0 -52
  75. package/dist/store/account-store.js +0 -18
  76. package/dist/store/health-store.d.ts +0 -16
  77. package/dist/store/health-store.js +0 -29
  78. package/dist/store/market-store.d.ts +0 -42
  79. package/dist/store/market-store.js +0 -51
  80. package/dist/store/order-store.d.ts +0 -38
  81. package/dist/store/order-store.js +0 -49
  82. package/dist/testing/create-fake-runtime.d.ts +0 -5
  83. package/dist/testing/create-fake-runtime.js +0 -7
  84. package/dist/types/public.d.ts +0 -5
  85. package/dist/types/public.js +0 -1
@@ -0,0 +1,889 @@
1
+ import BigNumber from "bignumber.js";
2
+ import type {
3
+ FundingRateStreamCallbacks,
4
+ FundingRateStreamOptions,
5
+ L1BookStreamCallbacks,
6
+ L1BookStreamOptions,
7
+ MarketAdapter,
8
+ RawFundingRateUpdate,
9
+ RawL1BookUpdate,
10
+ StreamHandle,
11
+ } from "../adapters/types.ts";
12
+ import type {
13
+ ClientContext,
14
+ HealthReporter,
15
+ ManagerLifecycle,
16
+ } from "../client/context.ts";
17
+ import { AcexError } from "../errors.ts";
18
+ import { AsyncEventBus } from "../internal/async-event-bus.ts";
19
+ import { matchesMarketFilter } from "../internal/filters.ts";
20
+ import type {
21
+ Exchange,
22
+ FundingRateSnapshot,
23
+ FundingRateUpdatedEvent,
24
+ L1Book,
25
+ L1BookUpdatedEvent,
26
+ MarketDataStatus,
27
+ MarketDataStreamStatus,
28
+ MarketDefinition,
29
+ MarketEvent,
30
+ MarketEventStreams,
31
+ MarketKeyInput,
32
+ MarketManager,
33
+ MarketStatusChangedEvent,
34
+ SubscribeFundingRateInput,
35
+ SubscribeL1BookInput,
36
+ SubscriptionActivity,
37
+ } from "../types/index.ts";
38
+
39
+ export interface MarketManagerOptions {
40
+ initialL1TimeoutMs?: number;
41
+ l1StaleAfterMs?: number;
42
+ l1ReconnectDelayMs?: number;
43
+ l1ReconnectMaxDelayMs?: number;
44
+ }
45
+
46
+ interface MarketRecord {
47
+ exchange: Exchange;
48
+ symbol: string;
49
+ market?: MarketDefinition;
50
+ l1Book?: L1Book;
51
+ fundingRate?: FundingRateSnapshot;
52
+ l1BookSubscribed: boolean;
53
+ fundingRateSubscribed: boolean;
54
+ l1Freshness?: "fresh" | "stale";
55
+ l1Reason?: MarketDataStatus["reason"];
56
+ fundingRateFreshness?: "fresh" | "stale";
57
+ fundingRateReason?: MarketDataStatus["reason"];
58
+ status: MarketDataStatus;
59
+ l1BookStream?: StreamHandle;
60
+ fundingRateStream?: StreamHandle;
61
+ }
62
+
63
+ const DEFAULT_INITIAL_L1_TIMEOUT_MS = 15_000;
64
+ const DEFAULT_L1_STALE_AFTER_MS = 15_000;
65
+ const DEFAULT_L1_RECONNECT_DELAY_MS = 1_000;
66
+ const DEFAULT_L1_RECONNECT_MAX_DELAY_MS = 10_000;
67
+
68
+ function marketKey(input: MarketKeyInput): string {
69
+ return `${input.exchange}:${input.symbol}`;
70
+ }
71
+
72
+ function cloneMarketStatus(status: MarketDataStatus): MarketDataStatus {
73
+ return { ...status };
74
+ }
75
+
76
+ function cloneStreamStatus(
77
+ status: MarketDataStreamStatus,
78
+ ): MarketDataStreamStatus {
79
+ return { ...status };
80
+ }
81
+
82
+ function cloneL1Book(book: L1Book): L1Book {
83
+ return { ...book, status: cloneStreamStatus(book.status) };
84
+ }
85
+
86
+ function cloneFundingRate(snapshot: FundingRateSnapshot): FundingRateSnapshot {
87
+ return { ...snapshot, status: cloneStreamStatus(snapshot.status) };
88
+ }
89
+
90
+ function cloneMarketDefinition(definition: MarketDefinition): MarketDefinition {
91
+ return { ...definition, raw: { ...definition.raw } };
92
+ }
93
+
94
+ export class MarketManagerImpl
95
+ implements MarketManager, ManagerLifecycle, HealthReporter<MarketDataStatus>
96
+ {
97
+ readonly events: MarketEventStreams;
98
+
99
+ private readonly context: ClientContext;
100
+ private readonly adapter: MarketAdapter;
101
+ private readonly marketBus = new AsyncEventBus<MarketEvent>();
102
+ private readonly marketStatusBus =
103
+ new AsyncEventBus<MarketStatusChangedEvent>();
104
+ private readonly definitions = new Map<string, MarketDefinition>();
105
+ private readonly records = new Map<string, MarketRecord>();
106
+ private catalogPromise: Promise<void> | undefined;
107
+ private readonly initialL1TimeoutMs: number;
108
+ private readonly l1StaleAfterMs: number;
109
+ private readonly l1ReconnectDelayMs: number;
110
+ private readonly l1ReconnectMaxDelayMs: number;
111
+
112
+ constructor(
113
+ context: ClientContext,
114
+ adapter: MarketAdapter,
115
+ options: MarketManagerOptions = {},
116
+ ) {
117
+ this.context = context;
118
+ this.adapter = adapter;
119
+ this.initialL1TimeoutMs =
120
+ options.initialL1TimeoutMs ?? DEFAULT_INITIAL_L1_TIMEOUT_MS;
121
+ this.l1StaleAfterMs = options.l1StaleAfterMs ?? DEFAULT_L1_STALE_AFTER_MS;
122
+ this.l1ReconnectDelayMs =
123
+ options.l1ReconnectDelayMs ?? DEFAULT_L1_RECONNECT_DELAY_MS;
124
+ this.l1ReconnectMaxDelayMs =
125
+ options.l1ReconnectMaxDelayMs ?? DEFAULT_L1_RECONNECT_MAX_DELAY_MS;
126
+
127
+ this.events = {
128
+ all: (filter) =>
129
+ this.marketBus.stream((event) => matchesMarketFilter(event, filter)),
130
+ fundingRateUpdates: (filter) =>
131
+ this.marketBus.stream(
132
+ (event): event is FundingRateUpdatedEvent =>
133
+ event.type === "funding_rate.updated" &&
134
+ matchesMarketFilter(event, filter),
135
+ ),
136
+ l1BookUpdates: (filter) =>
137
+ this.marketBus.stream(
138
+ (event): event is L1BookUpdatedEvent =>
139
+ event.type === "l1_book.updated" &&
140
+ matchesMarketFilter(event, filter),
141
+ ),
142
+ status: (filter) =>
143
+ this.marketStatusBus.stream((event) =>
144
+ matchesMarketFilter(event, filter),
145
+ ),
146
+ };
147
+ }
148
+
149
+ // --- MarketManager public API ---
150
+
151
+ async loadMarkets(): Promise<void> {
152
+ await this.loadMarketCatalog();
153
+ }
154
+
155
+ async subscribeL1Book(input: SubscribeL1BookInput): Promise<void> {
156
+ this.context.assertStarted();
157
+ const market = await this.resolveMarketDefinition(input);
158
+ const record = this.getOrCreateRecord({
159
+ exchange: input.exchange,
160
+ symbol: market.symbol,
161
+ });
162
+
163
+ record.market = market;
164
+ record.l1BookSubscribed = true;
165
+ record.l1Freshness = record.l1Book ? "stale" : undefined;
166
+ record.l1Reason = undefined;
167
+ this.recomputeAndPublishStatus(record);
168
+
169
+ await this.ensureL1BookStream(record, market);
170
+ }
171
+
172
+ async unsubscribeL1Book(input: SubscribeL1BookInput): Promise<void> {
173
+ const record = this.records.get(marketKey(input));
174
+ if (!record?.l1BookSubscribed) {
175
+ return;
176
+ }
177
+
178
+ record.l1BookStream?.close();
179
+ record.l1BookStream = undefined;
180
+ record.l1BookSubscribed = false;
181
+ record.l1Freshness = undefined;
182
+ record.l1Reason = undefined;
183
+ this.syncL1BookStatus(record);
184
+ this.recomputeAndPublishStatus(record, this.context.now());
185
+ }
186
+
187
+ async subscribeFundingRate(input: SubscribeFundingRateInput): Promise<void> {
188
+ this.context.assertStarted();
189
+ const market = await this.resolveMarketDefinition(input);
190
+ this.assertFundingRateSupported(market);
191
+ const record = this.getOrCreateRecord({
192
+ exchange: input.exchange,
193
+ symbol: market.symbol,
194
+ });
195
+
196
+ record.market = market;
197
+ record.fundingRateSubscribed = true;
198
+ record.fundingRateFreshness = record.fundingRate ? "stale" : undefined;
199
+ record.fundingRateReason = undefined;
200
+ this.recomputeAndPublishStatus(record);
201
+
202
+ await this.ensureFundingRateStream(record, market);
203
+ }
204
+
205
+ async unsubscribeFundingRate(
206
+ input: SubscribeFundingRateInput,
207
+ ): Promise<void> {
208
+ const record = this.records.get(marketKey(input));
209
+ if (!record?.fundingRateSubscribed) {
210
+ return;
211
+ }
212
+
213
+ record.fundingRateStream?.close();
214
+ record.fundingRateStream = undefined;
215
+ record.fundingRateSubscribed = false;
216
+ record.fundingRateFreshness = undefined;
217
+ record.fundingRateReason = undefined;
218
+ this.syncFundingRateStatus(record);
219
+ this.recomputeAndPublishStatus(record, this.context.now());
220
+ }
221
+
222
+ getMarket(exchange: Exchange, symbol: string): MarketDefinition | undefined {
223
+ const market = this.definitions.get(marketKey({ exchange, symbol }));
224
+ return market ? cloneMarketDefinition(market) : undefined;
225
+ }
226
+
227
+ findMarkets(symbol: string): MarketDefinition[] {
228
+ return [...this.definitions.values()]
229
+ .filter((market) => market.symbol === symbol)
230
+ .map((market) => cloneMarketDefinition(market));
231
+ }
232
+
233
+ listMarkets(exchange?: Exchange): MarketDefinition[] {
234
+ const values = [...this.definitions.values()];
235
+ const filtered = exchange
236
+ ? values.filter((market) => market.exchange === exchange)
237
+ : values;
238
+ return filtered
239
+ .sort((left, right) => left.symbol.localeCompare(right.symbol))
240
+ .map((market) => cloneMarketDefinition(market));
241
+ }
242
+
243
+ getL1Book(key: MarketKeyInput): L1Book | undefined {
244
+ const book = this.records.get(marketKey(key))?.l1Book;
245
+ return book ? cloneL1Book(book) : undefined;
246
+ }
247
+
248
+ getFundingRate(key: MarketKeyInput): FundingRateSnapshot | undefined {
249
+ const fundingRate = this.records.get(marketKey(key))?.fundingRate;
250
+ return fundingRate ? cloneFundingRate(fundingRate) : undefined;
251
+ }
252
+
253
+ getMarketStatus(key: MarketKeyInput): MarketDataStatus | undefined {
254
+ const status = this.records.get(marketKey(key))?.status;
255
+ return status ? cloneMarketStatus(status) : undefined;
256
+ }
257
+
258
+ // --- ManagerLifecycle ---
259
+
260
+ onClientStarted(): void {
261
+ const now = this.context.now();
262
+
263
+ for (const record of this.records.values()) {
264
+ if (!record.l1BookSubscribed && !record.fundingRateSubscribed) {
265
+ continue;
266
+ }
267
+
268
+ if (record.l1BookSubscribed) {
269
+ record.l1Freshness = record.l1Book ? "stale" : undefined;
270
+ record.l1Reason = undefined;
271
+ this.syncL1BookStatus(record);
272
+ }
273
+ if (record.fundingRateSubscribed) {
274
+ record.fundingRateFreshness = record.fundingRate ? "stale" : undefined;
275
+ record.fundingRateReason = undefined;
276
+ this.syncFundingRateStatus(record);
277
+ }
278
+ this.recomputeAndPublishStatus(record, now);
279
+ }
280
+
281
+ void this.resumeStreams();
282
+ }
283
+
284
+ onClientStopping(now: number): void {
285
+ for (const record of this.records.values()) {
286
+ if (!record.l1BookSubscribed && !record.fundingRateSubscribed) {
287
+ continue;
288
+ }
289
+
290
+ record.l1BookStream?.close();
291
+ record.l1BookStream = undefined;
292
+ record.fundingRateStream?.close();
293
+ record.fundingRateStream = undefined;
294
+ record.l1Freshness = record.l1Book ? "stale" : undefined;
295
+ record.fundingRateFreshness = record.fundingRate ? "stale" : undefined;
296
+ this.syncL1BookStatus(record, now, "inactive");
297
+ this.syncFundingRateStatus(record, now, "inactive");
298
+
299
+ record.status = {
300
+ ...record.status,
301
+ activity: "inactive",
302
+ inactiveSince: now,
303
+ freshness: record.l1Book || record.fundingRate ? "stale" : undefined,
304
+ };
305
+ this.publishStatus(record);
306
+ }
307
+ }
308
+
309
+ // --- HealthReporter ---
310
+
311
+ getStatuses(): MarketDataStatus[] {
312
+ return [...this.records.values()]
313
+ .map((record) => cloneMarketStatus(record.status))
314
+ .sort((left, right) =>
315
+ `${left.exchange}:${left.symbol}`.localeCompare(
316
+ `${right.exchange}:${right.symbol}`,
317
+ ),
318
+ );
319
+ }
320
+
321
+ // --- Internal helpers ---
322
+
323
+ private async loadMarketCatalog(): Promise<void> {
324
+ if (this.definitions.size > 0) {
325
+ return;
326
+ }
327
+
328
+ if (!this.catalogPromise) {
329
+ this.catalogPromise = this.fetchAndStoreMarketCatalog();
330
+ }
331
+
332
+ try {
333
+ await this.catalogPromise;
334
+ } finally {
335
+ if (this.definitions.size === 0) {
336
+ this.catalogPromise = undefined;
337
+ }
338
+ }
339
+ }
340
+
341
+ private async fetchAndStoreMarketCatalog(): Promise<void> {
342
+ try {
343
+ const markets = await this.adapter.loadMarkets();
344
+ this.definitions.clear();
345
+
346
+ for (const market of markets) {
347
+ this.definitions.set(marketKey(market), market);
348
+ }
349
+ } catch (error) {
350
+ const wrapped = new AcexError(
351
+ "MARKET_CATALOG_LOAD_FAILED",
352
+ "Failed to load market catalog from Binance",
353
+ );
354
+ this.context.publishRuntimeError(
355
+ "adapter",
356
+ error instanceof Error
357
+ ? error
358
+ : new Error("Unknown catalog load failure"),
359
+ { exchange: this.adapter.exchange },
360
+ );
361
+ throw wrapped;
362
+ }
363
+ }
364
+
365
+ private async resolveMarketDefinition(input: {
366
+ exchange: Exchange;
367
+ symbol: string;
368
+ }): Promise<MarketDefinition> {
369
+ this.assertSupportedExchange(input.exchange);
370
+ await this.loadMarketCatalog();
371
+
372
+ const market = this.definitions.get(marketKey(input));
373
+ if (!market) {
374
+ throw this.createError(
375
+ "MARKET_NOT_FOUND",
376
+ `Unknown market symbol: ${input.symbol}`,
377
+ { exchange: input.exchange, symbol: input.symbol },
378
+ "market",
379
+ );
380
+ }
381
+
382
+ if (!market.active) {
383
+ throw this.createError(
384
+ "MARKET_INACTIVE",
385
+ `Inactive market symbol: ${input.symbol}`,
386
+ { exchange: input.exchange, symbol: input.symbol },
387
+ "market",
388
+ );
389
+ }
390
+
391
+ return market;
392
+ }
393
+
394
+ private assertSupportedExchange(exchange: Exchange): void {
395
+ if (exchange === this.adapter.exchange) {
396
+ return;
397
+ }
398
+
399
+ throw this.createError(
400
+ "EXCHANGE_NOT_SUPPORTED",
401
+ `Exchange is not supported yet: ${exchange}`,
402
+ { exchange },
403
+ "client",
404
+ );
405
+ }
406
+
407
+ private assertFundingRateSupported(market: MarketDefinition): void {
408
+ if (market.contract && market.type === "swap") {
409
+ return;
410
+ }
411
+
412
+ throw this.createError(
413
+ "MARKET_FUNDING_RATE_UNSUPPORTED",
414
+ `Funding rate is not supported for market: ${market.symbol}`,
415
+ { exchange: market.exchange, symbol: market.symbol },
416
+ "market",
417
+ );
418
+ }
419
+
420
+ private getOrCreateRecord(input: {
421
+ exchange: Exchange;
422
+ symbol: string;
423
+ }): MarketRecord {
424
+ const key = marketKey(input);
425
+ const existing = this.records.get(key);
426
+ if (existing) {
427
+ return existing;
428
+ }
429
+
430
+ const record: MarketRecord = {
431
+ exchange: input.exchange,
432
+ symbol: input.symbol,
433
+ l1BookSubscribed: false,
434
+ fundingRateSubscribed: false,
435
+ status: {
436
+ exchange: input.exchange,
437
+ symbol: input.symbol,
438
+ activity: "inactive",
439
+ ready: false,
440
+ },
441
+ };
442
+
443
+ this.records.set(key, record);
444
+ return record;
445
+ }
446
+
447
+ private async ensureL1BookStream(
448
+ record: MarketRecord,
449
+ market: MarketDefinition,
450
+ ): Promise<void> {
451
+ if (record.l1BookStream) {
452
+ await record.l1BookStream.ready;
453
+ return;
454
+ }
455
+
456
+ record.l1BookStream = this.createL1BookStream(record, market);
457
+
458
+ try {
459
+ await record.l1BookStream.ready;
460
+ } catch {
461
+ record.l1BookStream = undefined;
462
+ const timeoutError = new AcexError(
463
+ "MARKET_STREAM_TIMEOUT",
464
+ `Timed out waiting for market data: ${market.symbol}`,
465
+ );
466
+ this.context.publishRuntimeError("runtime", timeoutError, {
467
+ exchange: market.exchange,
468
+ symbol: market.symbol,
469
+ });
470
+ this.updateConnectionState(record, "l1Book", "stale", "ws_disconnected");
471
+ throw timeoutError;
472
+ }
473
+ }
474
+
475
+ private async ensureFundingRateStream(
476
+ record: MarketRecord,
477
+ market: MarketDefinition,
478
+ ): Promise<void> {
479
+ if (record.fundingRateStream) {
480
+ await record.fundingRateStream.ready;
481
+ return;
482
+ }
483
+
484
+ record.fundingRateStream = this.createFundingRateStream(record, market);
485
+
486
+ try {
487
+ await record.fundingRateStream.ready;
488
+ } catch {
489
+ record.fundingRateStream = undefined;
490
+ const timeoutError = new AcexError(
491
+ "MARKET_STREAM_TIMEOUT",
492
+ `Timed out waiting for market data: ${market.symbol}`,
493
+ );
494
+ this.context.publishRuntimeError("runtime", timeoutError, {
495
+ exchange: market.exchange,
496
+ symbol: market.symbol,
497
+ });
498
+ this.updateConnectionState(
499
+ record,
500
+ "fundingRate",
501
+ "stale",
502
+ "ws_disconnected",
503
+ );
504
+ throw timeoutError;
505
+ }
506
+ }
507
+
508
+ private createL1BookStream(
509
+ record: MarketRecord,
510
+ market: MarketDefinition,
511
+ ): StreamHandle {
512
+ const callbacks: L1BookStreamCallbacks = {
513
+ onUpdate: (update: RawL1BookUpdate) => {
514
+ record.l1Book = this.createL1Book(
515
+ record.exchange,
516
+ record.symbol,
517
+ update,
518
+ record.l1Book,
519
+ );
520
+ record.l1Freshness = "fresh";
521
+ record.l1Reason = undefined;
522
+ this.syncL1BookStatus(record);
523
+
524
+ const event: L1BookUpdatedEvent = {
525
+ type: "l1_book.updated",
526
+ exchange: record.exchange,
527
+ symbol: record.symbol,
528
+ snapshot: cloneL1Book(record.l1Book),
529
+ ts: this.context.now(),
530
+ };
531
+
532
+ this.publishMarketEvent(event);
533
+ this.recomputeAndPublishStatus(record);
534
+ },
535
+ onFreshnessChange: (freshness, reason) => {
536
+ this.updateConnectionState(record, "l1Book", freshness, reason);
537
+ },
538
+ onDisconnected: () => {
539
+ this.updateConnectionState(
540
+ record,
541
+ "l1Book",
542
+ "stale",
543
+ "ws_disconnected",
544
+ );
545
+ },
546
+ onError: (error) => {
547
+ this.context.publishRuntimeError("runtime", error, {
548
+ exchange: record.exchange,
549
+ symbol: record.symbol,
550
+ });
551
+ },
552
+ };
553
+
554
+ const options: L1BookStreamOptions = {
555
+ initialMessageTimeoutMs: this.initialL1TimeoutMs,
556
+ staleAfterMs: this.l1StaleAfterMs,
557
+ reconnectDelayMs: this.l1ReconnectDelayMs,
558
+ reconnectMaxDelayMs: this.l1ReconnectMaxDelayMs,
559
+ now: () => this.context.now(),
560
+ };
561
+
562
+ return this.adapter.createL1BookStream(market, callbacks, options);
563
+ }
564
+
565
+ private createFundingRateStream(
566
+ record: MarketRecord,
567
+ market: MarketDefinition,
568
+ ): StreamHandle {
569
+ const callbacks: FundingRateStreamCallbacks = {
570
+ onUpdate: (update: RawFundingRateUpdate) => {
571
+ record.fundingRate = this.createFundingRate(
572
+ record.exchange,
573
+ record.symbol,
574
+ update,
575
+ record.fundingRate,
576
+ );
577
+ record.fundingRateFreshness = "fresh";
578
+ record.fundingRateReason = undefined;
579
+ this.syncFundingRateStatus(record);
580
+
581
+ const event: FundingRateUpdatedEvent = {
582
+ type: "funding_rate.updated",
583
+ exchange: record.exchange,
584
+ symbol: record.symbol,
585
+ snapshot: cloneFundingRate(record.fundingRate),
586
+ ts: this.context.now(),
587
+ };
588
+
589
+ this.publishMarketEvent(event);
590
+ this.recomputeAndPublishStatus(record);
591
+ },
592
+ onFreshnessChange: (freshness, reason) => {
593
+ this.updateConnectionState(record, "fundingRate", freshness, reason);
594
+ },
595
+ onDisconnected: () => {
596
+ this.updateConnectionState(
597
+ record,
598
+ "fundingRate",
599
+ "stale",
600
+ "ws_disconnected",
601
+ );
602
+ },
603
+ onError: (error) => {
604
+ this.context.publishRuntimeError("runtime", error, {
605
+ exchange: record.exchange,
606
+ symbol: record.symbol,
607
+ });
608
+ },
609
+ };
610
+
611
+ const options: FundingRateStreamOptions = {
612
+ initialMessageTimeoutMs: this.initialL1TimeoutMs,
613
+ staleAfterMs: this.l1StaleAfterMs,
614
+ reconnectDelayMs: this.l1ReconnectDelayMs,
615
+ reconnectMaxDelayMs: this.l1ReconnectMaxDelayMs,
616
+ now: () => this.context.now(),
617
+ };
618
+
619
+ return this.adapter.createFundingRateStream(market, callbacks, options);
620
+ }
621
+
622
+ private createL1Book(
623
+ exchange: Exchange,
624
+ symbol: string,
625
+ input: RawL1BookUpdate,
626
+ previous?: L1Book,
627
+ ): L1Book {
628
+ return {
629
+ exchange,
630
+ symbol,
631
+ bidPrice: new BigNumber(input.bidPrice),
632
+ bidSize: new BigNumber(input.bidSize),
633
+ askPrice: new BigNumber(input.askPrice),
634
+ askSize: new BigNumber(input.askSize),
635
+ exchangeTs: input.exchangeTs,
636
+ receivedAt: input.receivedAt,
637
+ updatedAt: input.receivedAt,
638
+ version: (previous?.version ?? 0) + 1,
639
+ status: previous?.status ?? {
640
+ activity: "active",
641
+ ready: true,
642
+ freshness: "fresh",
643
+ lastReceivedAt: input.receivedAt,
644
+ lastReadyAt: input.receivedAt,
645
+ },
646
+ };
647
+ }
648
+
649
+ private createFundingRate(
650
+ exchange: Exchange,
651
+ symbol: string,
652
+ input: RawFundingRateUpdate,
653
+ previous?: FundingRateSnapshot,
654
+ ): FundingRateSnapshot {
655
+ return {
656
+ exchange,
657
+ symbol,
658
+ fundingRate: new BigNumber(input.fundingRate),
659
+ nextFundingTime: input.nextFundingTime,
660
+ markPrice: input.markPrice ? new BigNumber(input.markPrice) : undefined,
661
+ indexPrice: input.indexPrice
662
+ ? new BigNumber(input.indexPrice)
663
+ : undefined,
664
+ exchangeTs: input.exchangeTs,
665
+ receivedAt: input.receivedAt,
666
+ updatedAt: input.receivedAt,
667
+ version: (previous?.version ?? 0) + 1,
668
+ status: previous?.status ?? {
669
+ activity: "active",
670
+ ready: true,
671
+ freshness: "fresh",
672
+ lastReceivedAt: input.receivedAt,
673
+ lastReadyAt: input.receivedAt,
674
+ },
675
+ };
676
+ }
677
+
678
+ private updateConnectionState(
679
+ record: MarketRecord,
680
+ stream: "l1Book" | "fundingRate",
681
+ freshness: "fresh" | "stale",
682
+ reason: MarketDataStatus["reason"],
683
+ ): void {
684
+ if (stream === "l1Book") {
685
+ record.l1Freshness = freshness;
686
+ record.l1Reason = reason;
687
+ this.syncL1BookStatus(record);
688
+ } else {
689
+ record.fundingRateFreshness = freshness;
690
+ record.fundingRateReason = reason;
691
+ this.syncFundingRateStatus(record);
692
+ }
693
+
694
+ this.recomputeAndPublishStatus(record);
695
+ }
696
+
697
+ private recomputeAndPublishStatus(
698
+ record: MarketRecord,
699
+ now = this.context.now(),
700
+ ): void {
701
+ const l1Ready = record.l1BookSubscribed && Boolean(record.l1Book);
702
+ const fundingRateReady =
703
+ record.fundingRateSubscribed && Boolean(record.fundingRate);
704
+ const active = record.l1BookSubscribed || record.fundingRateSubscribed;
705
+ const staleReason = record.l1Reason ?? record.fundingRateReason;
706
+ const freshness = this.resolveFreshness(record);
707
+
708
+ record.status = {
709
+ ...record.status,
710
+ activity: active ? "active" : "inactive",
711
+ ready: l1Ready || fundingRateReady,
712
+ freshness,
713
+ reason: freshness === "stale" ? staleReason : undefined,
714
+ inactiveSince: active ? undefined : now,
715
+ };
716
+
717
+ if (record.status.ready) {
718
+ record.status.lastReceivedAt = this.resolveLastReceivedAt(record);
719
+ record.status.lastReadyAt = this.resolveLastReadyAt(record);
720
+ }
721
+
722
+ this.publishStatus(record);
723
+ }
724
+
725
+ private syncL1BookStatus(
726
+ record: MarketRecord,
727
+ now?: number,
728
+ activity?: SubscriptionActivity,
729
+ ): void {
730
+ if (!record.l1Book) {
731
+ return;
732
+ }
733
+
734
+ record.l1Book.status = this.createStreamStatus(
735
+ activity ?? (record.l1BookSubscribed ? "active" : "inactive"),
736
+ true,
737
+ record.l1Freshness,
738
+ record.l1Reason,
739
+ record.l1Book.receivedAt,
740
+ record.l1Book.updatedAt,
741
+ now,
742
+ );
743
+ }
744
+
745
+ private syncFundingRateStatus(
746
+ record: MarketRecord,
747
+ now?: number,
748
+ activity?: SubscriptionActivity,
749
+ ): void {
750
+ if (!record.fundingRate) {
751
+ return;
752
+ }
753
+
754
+ record.fundingRate.status = this.createStreamStatus(
755
+ activity ?? (record.fundingRateSubscribed ? "active" : "inactive"),
756
+ true,
757
+ record.fundingRateFreshness,
758
+ record.fundingRateReason,
759
+ record.fundingRate.receivedAt,
760
+ record.fundingRate.updatedAt,
761
+ now,
762
+ );
763
+ }
764
+
765
+ private createStreamStatus(
766
+ activity: SubscriptionActivity,
767
+ ready: boolean,
768
+ freshness: MarketDataStreamStatus["freshness"],
769
+ reason: MarketDataStreamStatus["reason"],
770
+ lastReceivedAt?: number,
771
+ lastReadyAt?: number,
772
+ now = this.context.now(),
773
+ ): MarketDataStreamStatus {
774
+ return {
775
+ activity,
776
+ ready,
777
+ freshness,
778
+ reason: freshness === "stale" ? reason : undefined,
779
+ lastReceivedAt,
780
+ lastReadyAt,
781
+ inactiveSince: activity === "active" ? undefined : now,
782
+ };
783
+ }
784
+
785
+ private resolveFreshness(
786
+ record: MarketRecord,
787
+ ): MarketDataStatus["freshness"] | undefined {
788
+ if (record.l1BookSubscribed && record.l1Freshness === "stale") {
789
+ return "stale";
790
+ }
791
+ if (
792
+ record.fundingRateSubscribed &&
793
+ record.fundingRateFreshness === "stale"
794
+ ) {
795
+ return "stale";
796
+ }
797
+ if (record.l1BookSubscribed && record.l1Freshness === "fresh") {
798
+ return "fresh";
799
+ }
800
+ if (
801
+ record.fundingRateSubscribed &&
802
+ record.fundingRateFreshness === "fresh"
803
+ ) {
804
+ return "fresh";
805
+ }
806
+
807
+ return undefined;
808
+ }
809
+
810
+ private resolveLastReceivedAt(record: MarketRecord): number | undefined {
811
+ return Math.max(
812
+ record.l1Book?.receivedAt ?? 0,
813
+ record.fundingRate?.receivedAt ?? 0,
814
+ );
815
+ }
816
+
817
+ private resolveLastReadyAt(record: MarketRecord): number | undefined {
818
+ return Math.max(
819
+ record.l1Book?.updatedAt ?? 0,
820
+ record.fundingRate?.updatedAt ?? 0,
821
+ );
822
+ }
823
+
824
+ private publishMarketEvent(event: MarketEvent): void {
825
+ this.marketBus.publish(event);
826
+ }
827
+
828
+ private publishStatus(record: MarketRecord): void {
829
+ const event: MarketStatusChangedEvent = {
830
+ type: "market.status_changed",
831
+ exchange: record.exchange,
832
+ symbol: record.symbol,
833
+ status: cloneMarketStatus(record.status),
834
+ ts: this.context.now(),
835
+ };
836
+
837
+ this.marketStatusBus.publish(event);
838
+ this.marketBus.publish(event);
839
+ this.context.publishHealthEvent(event);
840
+ }
841
+
842
+ private async resumeStreams(): Promise<void> {
843
+ for (const record of this.records.values()) {
844
+ const market = record.market;
845
+ if (!market) {
846
+ continue;
847
+ }
848
+
849
+ if (record.l1BookSubscribed && !record.l1BookStream) {
850
+ try {
851
+ record.l1Freshness = record.l1Book ? "stale" : undefined;
852
+ record.l1Reason = undefined;
853
+ this.recomputeAndPublishStatus(record);
854
+ await this.ensureL1BookStream(record, market);
855
+ } catch {
856
+ // Errors are already published through the runtime error bus.
857
+ }
858
+ }
859
+
860
+ if (record.fundingRateSubscribed && !record.fundingRateStream) {
861
+ try {
862
+ record.fundingRateFreshness = record.fundingRate
863
+ ? "stale"
864
+ : undefined;
865
+ record.fundingRateReason = undefined;
866
+ this.recomputeAndPublishStatus(record);
867
+ await this.ensureFundingRateStream(record, market);
868
+ } catch {
869
+ // Errors are already published through the runtime error bus.
870
+ }
871
+ }
872
+ }
873
+ }
874
+
875
+ private createError(
876
+ code:
877
+ | "MARKET_NOT_FOUND"
878
+ | "MARKET_INACTIVE"
879
+ | "MARKET_FUNDING_RATE_UNSUPPORTED"
880
+ | "EXCHANGE_NOT_SUPPORTED",
881
+ message: string,
882
+ metadata?: { exchange?: Exchange; symbol?: string },
883
+ source: "market" | "client" = "market",
884
+ ): AcexError {
885
+ const error = new AcexError(code, message);
886
+ this.context.publishRuntimeError(source, error, metadata);
887
+ return error;
888
+ }
889
+ }