@solncebro/market-data-feeder-lib 0.1.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 (115) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +60 -0
  3. package/dist/client/dataStaleness.d.ts +18 -0
  4. package/dist/client/dataStaleness.d.ts.map +1 -0
  5. package/dist/client/dataStaleness.js +23 -0
  6. package/dist/client/dataStaleness.js.map +1 -0
  7. package/dist/client/marketDataClient.d.ts +47 -0
  8. package/dist/client/marketDataClient.d.ts.map +1 -0
  9. package/dist/client/marketDataClient.js +262 -0
  10. package/dist/client/marketDataClient.js.map +1 -0
  11. package/dist/client/marketDataClient.types.d.ts +15 -0
  12. package/dist/client/marketDataClient.types.d.ts.map +1 -0
  13. package/dist/client/marketDataClient.types.js +2 -0
  14. package/dist/client/marketDataClient.types.js.map +1 -0
  15. package/dist/client/marketDataSource.types.d.ts +27 -0
  16. package/dist/client/marketDataSource.types.d.ts.map +1 -0
  17. package/dist/client/marketDataSource.types.js +2 -0
  18. package/dist/client/marketDataSource.types.js.map +1 -0
  19. package/dist/client/mirrorStore.d.ts +29 -0
  20. package/dist/client/mirrorStore.d.ts.map +1 -0
  21. package/dist/client/mirrorStore.js +119 -0
  22. package/dist/client/mirrorStore.js.map +1 -0
  23. package/dist/client/mirrorStore.types.d.ts +20 -0
  24. package/dist/client/mirrorStore.types.d.ts.map +1 -0
  25. package/dist/client/mirrorStore.types.js +2 -0
  26. package/dist/client/mirrorStore.types.js.map +1 -0
  27. package/dist/domain/constants.d.ts +12 -0
  28. package/dist/domain/constants.d.ts.map +1 -0
  29. package/dist/domain/constants.js +47 -0
  30. package/dist/domain/constants.js.map +1 -0
  31. package/dist/domain/events.types.d.ts +14 -0
  32. package/dist/domain/events.types.d.ts.map +1 -0
  33. package/dist/domain/events.types.js +2 -0
  34. package/dist/domain/events.types.js.map +1 -0
  35. package/dist/domain/feederSource.types.d.ts +44 -0
  36. package/dist/domain/feederSource.types.d.ts.map +1 -0
  37. package/dist/domain/feederSource.types.js +2 -0
  38. package/dist/domain/feederSource.types.js.map +1 -0
  39. package/dist/domain/managedSource.types.d.ts +13 -0
  40. package/dist/domain/managedSource.types.d.ts.map +1 -0
  41. package/dist/domain/managedSource.types.js +2 -0
  42. package/dist/domain/managedSource.types.js.map +1 -0
  43. package/dist/domain/marketData.types.d.ts +29 -0
  44. package/dist/domain/marketData.types.d.ts.map +1 -0
  45. package/dist/domain/marketData.types.js +2 -0
  46. package/dist/domain/marketData.types.js.map +1 -0
  47. package/dist/domain/snapshot.types.d.ts +10 -0
  48. package/dist/domain/snapshot.types.d.ts.map +1 -0
  49. package/dist/domain/snapshot.types.js +2 -0
  50. package/dist/domain/snapshot.types.js.map +1 -0
  51. package/dist/domain/subscription.types.d.ts +11 -0
  52. package/dist/domain/subscription.types.d.ts.map +1 -0
  53. package/dist/domain/subscription.types.js +2 -0
  54. package/dist/domain/subscription.types.js.map +1 -0
  55. package/dist/embedded/embeddedMarketDataSource.d.ts +29 -0
  56. package/dist/embedded/embeddedMarketDataSource.d.ts.map +1 -0
  57. package/dist/embedded/embeddedMarketDataSource.js +121 -0
  58. package/dist/embedded/embeddedMarketDataSource.js.map +1 -0
  59. package/dist/embedded/embeddedMarketDataSource.types.d.ts +17 -0
  60. package/dist/embedded/embeddedMarketDataSource.types.d.ts.map +1 -0
  61. package/dist/embedded/embeddedMarketDataSource.types.js +2 -0
  62. package/dist/embedded/embeddedMarketDataSource.types.js.map +1 -0
  63. package/dist/index.d.ts +28 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +30 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/protocol/clientMessageValidation.d.ts +3 -0
  68. package/dist/protocol/clientMessageValidation.d.ts.map +1 -0
  69. package/dist/protocol/clientMessageValidation.js +41 -0
  70. package/dist/protocol/clientMessageValidation.js.map +1 -0
  71. package/dist/protocol/codec.d.ts +5 -0
  72. package/dist/protocol/codec.d.ts.map +1 -0
  73. package/dist/protocol/codec.js +40 -0
  74. package/dist/protocol/codec.js.map +1 -0
  75. package/dist/protocol/messages.types.d.ts +65 -0
  76. package/dist/protocol/messages.types.d.ts.map +1 -0
  77. package/dist/protocol/messages.types.js +2 -0
  78. package/dist/protocol/messages.types.js.map +1 -0
  79. package/dist/source/gapRepair.d.ts +15 -0
  80. package/dist/source/gapRepair.d.ts.map +1 -0
  81. package/dist/source/gapRepair.js +40 -0
  82. package/dist/source/gapRepair.js.map +1 -0
  83. package/dist/source/indicators.d.ts +5 -0
  84. package/dist/source/indicators.d.ts.map +1 -0
  85. package/dist/source/indicators.js +21 -0
  86. package/dist/source/indicators.js.map +1 -0
  87. package/dist/source/marketDataManager.d.ts +94 -0
  88. package/dist/source/marketDataManager.d.ts.map +1 -0
  89. package/dist/source/marketDataManager.js +930 -0
  90. package/dist/source/marketDataManager.js.map +1 -0
  91. package/dist/source/massStale.d.ts +10 -0
  92. package/dist/source/massStale.d.ts.map +1 -0
  93. package/dist/source/massStale.js +8 -0
  94. package/dist/source/massStale.js.map +1 -0
  95. package/dist/source/symbolListDelta.d.ts +4 -0
  96. package/dist/source/symbolListDelta.d.ts.map +1 -0
  97. package/dist/source/symbolListDelta.js +9 -0
  98. package/dist/source/symbolListDelta.js.map +1 -0
  99. package/dist/source/symbolListDelta.types.d.ts +10 -0
  100. package/dist/source/symbolListDelta.types.d.ts.map +1 -0
  101. package/dist/source/symbolListDelta.types.js +2 -0
  102. package/dist/source/symbolListDelta.types.js.map +1 -0
  103. package/dist/source/symbolRemovalGuard.d.ts +14 -0
  104. package/dist/source/symbolRemovalGuard.d.ts.map +1 -0
  105. package/dist/source/symbolRemovalGuard.js +19 -0
  106. package/dist/source/symbolRemovalGuard.js.map +1 -0
  107. package/dist/utils/intervalScheduler.d.ts +16 -0
  108. package/dist/utils/intervalScheduler.d.ts.map +1 -0
  109. package/dist/utils/intervalScheduler.js +42 -0
  110. package/dist/utils/intervalScheduler.js.map +1 -0
  111. package/dist/utils/timeout.d.ts +3 -0
  112. package/dist/utils/timeout.d.ts.map +1 -0
  113. package/dist/utils/timeout.js +18 -0
  114. package/dist/utils/timeout.js.map +1 -0
  115. package/package.json +37 -0
@@ -0,0 +1,930 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { MarketTypeEnum, RateLimitedRequestQueue, TradifiSymbolGate, logger } from '@solncebro/trade-engine';
3
+ import { KLINE_BUFFER_SIZE, STALENESS_THRESHOLD_MULTIPLIER, resolveIntervalMs } from '../domain/constants.js';
4
+ import { calculateAllMaValues } from './indicators.js';
5
+ import { crossesMassStaleThreshold } from './massStale.js';
6
+ import { computeSymbolListDelta } from './symbolListDelta.js';
7
+ import { evaluateSymbolRemovalList } from './symbolRemovalGuard.js';
8
+ import { mergeRepairedKlines } from './gapRepair.js';
9
+ import { withTimeout } from '../utils/timeout.js';
10
+ import { startIntervalScheduler } from '../utils/intervalScheduler.js';
11
+ const KLINE_UPDATE_THROTTLE_MS = 5000;
12
+ const STALENESS_CHECK_INTERVAL_MS = 60_000;
13
+ const STALENESS_HEARTBEAT_EVERY_N_TICKS = 15;
14
+ const LOAD_KLINES_TIMEOUT_MS = 60_000;
15
+ const PERSISTENT_STALE_THRESHOLD_TICK_COUNT = 10;
16
+ const KLINE_LOAD_PROGRESS_LOG_EVERY = 100;
17
+ const SILENCE_CHECK_INTERVAL_MS = 10_000;
18
+ const SILENCE_THRESHOLD_MS = 45_000;
19
+ const VOLUME_REFRESH_INTERVAL_MS = 30_000;
20
+ const MASS_STALE_RATIO_THRESHOLD = 0.3;
21
+ const MASS_STALE_MIN_SYMBOLS = 20;
22
+ // Gap repair: a candle missed by the stream (or sealed locally without the exchange's closing
23
+ // frame) is healed by a REST refetch merged into the buffer, then pushed to clients as a reseed.
24
+ // The per-symbol cooldown bounds the REST cost of sparse symbols whose "gaps" are legit (the
25
+ // exchange never created the candle), and the concurrency cap keeps a mass-gap avalanche after a
26
+ // transport blip from monopolizing the shared backfill pacer.
27
+ const GAP_REPAIR_COOLDOWN_MS = 600_000;
28
+ const MAX_CONCURRENT_GAP_REPAIRS = 3;
29
+ const GAP_REPAIR_BASE_FETCH_COUNT = 2;
30
+ // A symbol whose buffer sits far below the history its listing time implies (a truncated REST load —
31
+ // the exchange momentarily served a few candles instead of the full window) is re-backfilled through
32
+ // the gap-repair queue until it fills, without waiting for a full process restart. The tolerance
33
+ // absorbs the newest forming/edge candles so a healthy, fully-loaded symbol is never flagged.
34
+ const UNDERGROWN_TOLERANCE = 3;
35
+ // A latched (converged) symbol is still re-probed at a slow cadence: the exchange's "no more
36
+ // candles" answer may itself have been a persistent transient, and an hourly full-window probe
37
+ // heals it within the hour instead of never.
38
+ const CONVERGED_REPROBE_INTERVAL_MS = 3_600_000;
39
+ // After this many CONSECUTIVE hourly re-probes reconfirm the convergence, the exchange's answer is
40
+ // accepted as the symbol's true history start: a contract that started trading later than its
41
+ // listing time is the norm (Bybit announces ~a day ahead), not an anomaly to alarm on for weeks —
42
+ // the symbol leaves the digest and is measured from its own first candle from then on.
43
+ const CONVERGED_ACCEPT_REPROBE_COUNT = 2;
44
+ // Fallback pacer for the no-arg constructor (tests). Production injects an exchange-tuned queue from
45
+ // feeder.ts (resolveBackfillRequestsPerSecond) — Binance must run slower than this to stay under its
46
+ // per-IP REQUEST_WEIGHT budget.
47
+ const KLINE_BACKFILL_REQUESTS_PER_SECOND = 80;
48
+ const DEFAULT_BACKFILL_QUEUE = new RateLimitedRequestQueue({
49
+ rateLimit: KLINE_BACKFILL_REQUESTS_PER_SECOND,
50
+ intervalMs: 1000,
51
+ loggerLabel: '[MarketData:backfill]',
52
+ });
53
+ const ZERO_MA = { ma25: 0, ma50: 0, ma100: 0, ma200: 0 };
54
+ export function markBackfilledHistoryClosed(klineList) {
55
+ for (let index = 0; index < klineList.length - 1; index++) {
56
+ if (klineList[index].isClosed !== true) {
57
+ klineList[index].isClosed = true;
58
+ }
59
+ }
60
+ }
61
+ class MarketDataManager extends EventEmitter {
62
+ exchangeConnector;
63
+ tradifiSymbolGate;
64
+ interval;
65
+ klineListBySymbol = new Map();
66
+ maValuesBySymbol = new Map();
67
+ subscriptionBySymbol = new Map();
68
+ handlerBySymbol = new Map();
69
+ currentKlineBySymbol = new Map();
70
+ lastEmittedUpdateBySymbol = new Map();
71
+ consecutiveStaleScanCountBySymbol = new Map();
72
+ persistentStaleEmittedSet = new Set();
73
+ skippedOlderKlineCountBySymbol = new Map();
74
+ throttledMaRecomputeCount = 0;
75
+ skippedStaleEmitCount = 0;
76
+ lastInboundAtMs = 0;
77
+ isStreamSilent = false;
78
+ isMassStale = false;
79
+ isShutDown = false;
80
+ pendingRemovalSet = new Set();
81
+ isSymbolSyncAnomalyActive = false;
82
+ lastGapRepairAtMsBySymbol = new Map();
83
+ gapRepairInFlightSet = new Set();
84
+ gapRepairPendingSymbolList = [];
85
+ gapRepairFetchCountBySymbol = new Map();
86
+ // Symbols whose backfill has converged — a refetch returned data but added nothing while the buffer
87
+ // is still below the expected size (the exchange genuinely serves fewer candles than the listing
88
+ // time implies, e.g. a contract announced long before its first candle). Re-scheduling stops until
89
+ // the buffer changes, so a symbol with a short real history does not retry forever.
90
+ backfillConvergedSet = new Set();
91
+ // First converged measurement (the pending vote). The latch requires TWO full-window convergences
92
+ // in a row: the incident this machinery exists for IS a transient full-window truncation, so a
93
+ // single measurement must never disable the backfill (mirrors the mass-removal
94
+ // confirm-on-second-sync guard). Any merge that adds data clears the vote.
95
+ pendingBackfillConvergeSet = new Set();
96
+ // Consecutive hourly re-probes that reconfirmed a latched convergence; at
97
+ // CONVERGED_ACCEPT_REPROBE_COUNT the short history is accepted as the exchange's truth.
98
+ convergedReprobeCountBySymbol = new Map();
99
+ // Exchange-confirmed history start (the oldest candle of a repeatedly reconfirmed short history).
100
+ // Overrides the listing time in the expectation so an accepted symbol reads healthy.
101
+ acceptedHistoryStartMsBySymbol = new Map();
102
+ locallyClosedCandleCount = 0;
103
+ stalenessSchedulerHandle = null;
104
+ silenceSchedulerHandle = null;
105
+ volumeSchedulerHandle = null;
106
+ lastEmittedVolumeBySymbol = new Map();
107
+ backfillQueue;
108
+ constructor(exchangeConnector, interval, backfillQueue = DEFAULT_BACKFILL_QUEUE) {
109
+ super();
110
+ this.exchangeConnector = exchangeConnector;
111
+ this.tradifiSymbolGate = new TradifiSymbolGate({ connector: exchangeConnector });
112
+ this.interval = interval;
113
+ this.backfillQueue = backfillQueue;
114
+ }
115
+ start() {
116
+ this.startStalenessWatchdog();
117
+ this.startSilenceWatchdog();
118
+ this.startVolumeRefreshScheduler();
119
+ }
120
+ async loadAllSymbols() {
121
+ const allSymbolList = await this.tradifiSymbolGate.loadUniverse();
122
+ // Teardown can land at any await of this method (the registry defers it until the load settles,
123
+ // but the source may also be shut down directly) — from here on every stage bails out on
124
+ // isShutDown so a dead source never enqueues backfill work or subscribes exchange streams.
125
+ if (this.isShutDown) {
126
+ logger.warn({}, `[MarketData] loadAllSymbols aborted — source shut down during the symbol-list fetch [${this.interval}]`);
127
+ return;
128
+ }
129
+ const usdtSymbolList = allSymbolList.filter((symbol) => symbol.endsWith('USDT'));
130
+ logger.info({ symbolCount: usdtSymbolList.length, filtered: allSymbolList.length - usdtSymbolList.length }, `[MarketData] Starting kline loading: ${usdtSymbolList.length} USDT symbols [${this.interval}]`);
131
+ this.emit('intervalLoadStarted', usdtSymbolList.length);
132
+ await this.loadKlines(usdtSymbolList);
133
+ if (this.isShutDown) {
134
+ logger.warn({ requestedCount: usdtSymbolList.length }, `[MarketData] loadAllSymbols aborted — source shut down during backfill; skipping subscriptions and load accounting [${this.interval}]`);
135
+ return;
136
+ }
137
+ this.subscribeToKlines(usdtSymbolList);
138
+ const loadedCount = this.klineListBySymbol.size;
139
+ const failedSymbolList = usdtSymbolList.filter((symbol) => !this.klineListBySymbol.has(symbol));
140
+ for (const symbol of failedSymbolList) {
141
+ this.emit('symbolLoadFailed', symbol);
142
+ }
143
+ logger.info({ loadedCount, requestedCount: usdtSymbolList.length, failedCount: failedSymbolList.length }, `[MarketData] All klines loaded (${loadedCount}/${usdtSymbolList.length} symbols), subscriptions active [${this.interval}]`);
144
+ this.emit('intervalLoadCompleted', loadedCount);
145
+ }
146
+ getInterval() {
147
+ return this.interval;
148
+ }
149
+ getIntervalMs() {
150
+ return resolveIntervalMs(this.interval);
151
+ }
152
+ getMaValues(symbol) {
153
+ return this.maValuesBySymbol.get(symbol) ?? ZERO_MA;
154
+ }
155
+ getKlineList(symbol) {
156
+ return this.klineListBySymbol.get(symbol) ?? [];
157
+ }
158
+ getCurrentKline(symbol) {
159
+ return this.currentKlineBySymbol.get(symbol);
160
+ }
161
+ getSymbolList() {
162
+ return Array.from(this.klineListBySymbol.keys());
163
+ }
164
+ getKlineCount() {
165
+ let total = 0;
166
+ for (const klineList of this.klineListBySymbol.values()) {
167
+ total += klineList.length;
168
+ }
169
+ return total;
170
+ }
171
+ getLastUpdateTimestamp(symbol) {
172
+ return this.lastEmittedUpdateBySymbol.get(symbol)?.emittedAtMs;
173
+ }
174
+ getLastKlineOpenTimestamp(symbol) {
175
+ const klineList = this.klineListBySymbol.get(symbol);
176
+ if (klineList === undefined || klineList.length === 0) {
177
+ return undefined;
178
+ }
179
+ return klineList[klineList.length - 1].openTimestamp;
180
+ }
181
+ getVolume24h(symbol) {
182
+ const ticker = this.exchangeConnector.getTicker(symbol, MarketTypeEnum.Futures);
183
+ return ticker?.quoteVolume ?? Number.POSITIVE_INFINITY;
184
+ }
185
+ getSubscriptionCount() {
186
+ return this.subscriptionBySymbol.size;
187
+ }
188
+ getStreamLiveness() {
189
+ const silenceMs = this.lastInboundAtMs > 0 ? Date.now() - this.lastInboundAtMs : 0;
190
+ return { lastInboundAtMs: this.lastInboundAtMs, silenceMs, isStreamSilent: this.isStreamSilent };
191
+ }
192
+ getFreshSymbolCount() {
193
+ return this.klineListBySymbol.size - this.getStaleSymbolList().length;
194
+ }
195
+ getPersistentStaleCount() {
196
+ return this.consecutiveStaleScanCountBySymbol.size;
197
+ }
198
+ getStaleSymbolList() {
199
+ const intervalMs = resolveIntervalMs(this.interval);
200
+ const thresholdMs = STALENESS_THRESHOLD_MULTIPLIER * intervalMs;
201
+ const nowMs = Date.now();
202
+ const staleSymbolList = [];
203
+ for (const [symbol, klineList] of this.klineListBySymbol) {
204
+ if (klineList.length === 0) {
205
+ continue;
206
+ }
207
+ const lastKline = klineList[klineList.length - 1];
208
+ const referenceTimestamp = lastKline.closeTimestamp > 0 ? lastKline.closeTimestamp : lastKline.openTimestamp + intervalMs;
209
+ const ageMs = nowMs - referenceTimestamp;
210
+ if (ageMs > thresholdMs) {
211
+ staleSymbolList.push({ symbol, ageMs });
212
+ }
213
+ }
214
+ staleSymbolList.sort((first, second) => second.ageMs - first.ageMs);
215
+ return staleSymbolList;
216
+ }
217
+ async ensureSymbolLoaded(symbol) {
218
+ if (this.klineListBySymbol.has(symbol)) {
219
+ return 'alreadyLoaded';
220
+ }
221
+ const exchangeClient = this.exchangeConnector.futures;
222
+ const exchangeSymbolList = await this.tradifiSymbolGate.loadUniverse();
223
+ // A non-empty exchange list that lacks the symbol = legit absence (delisted / never listed / a
224
+ // typo that passed the boundary pattern) — an empty snapshot is the correct answer, not an
225
+ // endless retry loop. An EMPTY list means the list read itself failed (getFuturesSymbols
226
+ // swallows errors into []) — fall through to the REST fetch, which reports its own failure.
227
+ if (exchangeSymbolList.length > 0 && !exchangeSymbolList.includes(symbol)) {
228
+ logger.warn({ symbol }, `[MarketData] ${symbol} is not on the exchange — skipping load [${this.interval}]`);
229
+ return 'notOnExchange';
230
+ }
231
+ logger.info({ symbol }, `[MarketData] ${symbol} ensureSymbolLoaded fetchKlines request limit=${KLINE_BUFFER_SIZE} [${this.interval}]`);
232
+ try {
233
+ const rawKlineList = await this.backfillQueue.execute(() => (this.isShutDown ? Promise.resolve([]) : this.fetchKlinesWithTimeout(exchangeClient.fetchKlines(symbol, this.interval, { limit: KLINE_BUFFER_SIZE }), LOAD_KLINES_TIMEOUT_MS, symbol)), `ensureSymbolLoaded ${symbol} [${this.interval}]`);
234
+ if (this.isShutDown) {
235
+ return 'aborted';
236
+ }
237
+ // The bulk load may have populated (and subscribed) this symbol while our fetch was queued
238
+ // behind the shared pacer — its live buffer wins over this stale copy.
239
+ if (this.klineListBySymbol.has(symbol)) {
240
+ return 'alreadyLoaded';
241
+ }
242
+ const klineList = rawKlineList.slice(-KLINE_BUFFER_SIZE);
243
+ logger.info({ symbol, klineCount: klineList.length }, `[MarketData] ${symbol} ensureSymbolLoaded fetchKlines response klineCount=${klineList.length} [${this.interval}]`);
244
+ // Listed but no candles yet (e.g. a contract published before trading starts): a legit
245
+ // absence, not a transient failure — throwing here would blank a whole multi-symbol scope
246
+ // into a 4001 retry loop because of one such symbol. The digest still hears about it.
247
+ if (klineList.length === 0) {
248
+ logger.warn({ symbol }, `[MarketData] ${symbol} ensureSymbolLoaded — empty kline history, skipping subscription [${this.interval}]`);
249
+ this.emit('symbolLoadFailed', symbol);
250
+ return 'noHistory';
251
+ }
252
+ markBackfilledHistoryClosed(klineList);
253
+ this.klineListBySymbol.set(symbol, klineList);
254
+ const lastKline = klineList[klineList.length - 1];
255
+ if (lastKline.isClosed !== true) {
256
+ this.currentKlineBySymbol.set(symbol, lastKline);
257
+ }
258
+ this.recalculateAndStoreMa(symbol, klineList);
259
+ this.subscribeToKlines([symbol]);
260
+ logger.info({ symbol }, `[MarketData] ${symbol} ensureSymbolLoaded — subscribed [${this.interval}]`);
261
+ this.emit('symbolLoadCompleted', symbol);
262
+ return 'loaded';
263
+ }
264
+ catch (error) {
265
+ logger.error({ symbol, error }, `[MarketData] ${symbol} ensureSymbolLoaded failed [${this.interval}]`);
266
+ this.emit('symbolLoadFailed', symbol);
267
+ throw error;
268
+ }
269
+ }
270
+ releaseSymbol(symbol) {
271
+ // Tell the health monitor the symbol's streams are gone for good (its digest keys can never
272
+ // recover), and pull it from the gap-repair queue so a released symbol does not burn a paced
273
+ // REST request on a buffer that no longer exists.
274
+ this.emit('symbolReleased', symbol);
275
+ const pendingRepairIndex = this.gapRepairPendingSymbolList.indexOf(symbol);
276
+ if (pendingRepairIndex !== -1) {
277
+ this.gapRepairPendingSymbolList.splice(pendingRepairIndex, 1);
278
+ }
279
+ const exchangeClient = this.exchangeConnector.futures;
280
+ const subscription = this.subscriptionBySymbol.get(symbol);
281
+ if (subscription !== undefined) {
282
+ try {
283
+ exchangeClient.unsubscribeKlines(subscription);
284
+ }
285
+ catch (error) {
286
+ logger.warn({ symbol, error }, `[MarketData] ${symbol} releaseSymbol — unsubscribeKlines failed (continuing cleanup) [${this.interval}]`);
287
+ }
288
+ }
289
+ this.subscriptionBySymbol.delete(symbol);
290
+ this.handlerBySymbol.delete(symbol);
291
+ this.klineListBySymbol.delete(symbol);
292
+ this.maValuesBySymbol.delete(symbol);
293
+ this.currentKlineBySymbol.delete(symbol);
294
+ this.lastEmittedUpdateBySymbol.delete(symbol);
295
+ this.consecutiveStaleScanCountBySymbol.delete(symbol);
296
+ this.persistentStaleEmittedSet.delete(symbol);
297
+ this.skippedOlderKlineCountBySymbol.delete(symbol);
298
+ this.lastEmittedVolumeBySymbol.delete(symbol);
299
+ this.lastGapRepairAtMsBySymbol.delete(symbol);
300
+ this.gapRepairFetchCountBySymbol.delete(symbol);
301
+ this.backfillConvergedSet.delete(symbol);
302
+ this.pendingBackfillConvergeSet.delete(symbol);
303
+ this.convergedReprobeCountBySymbol.delete(symbol);
304
+ this.acceptedHistoryStartMsBySymbol.delete(symbol);
305
+ }
306
+ async syncAllSymbols() {
307
+ try {
308
+ // reloadUniverse refreshes the exchange symbol cache first — without it the hourly sync
309
+ // compares the cached list against itself and can never see a listing or delisting.
310
+ const allSymbolList = await this.tradifiSymbolGate.reloadUniverse();
311
+ const usdtSymbolList = allSymbolList.filter((symbol) => symbol.endsWith('USDT'));
312
+ if (usdtSymbolList.length === 0) {
313
+ logger.warn({ loadedSymbolCount: this.klineListBySymbol.size }, `[MarketData] Symbol sync skipped — exchange returned an empty symbol list [${this.interval}]`);
314
+ return;
315
+ }
316
+ const loadedSymbolCount = this.klineListBySymbol.size;
317
+ const delta = computeSymbolListDelta({ loadedSymbolList: this.getSymbolList(), exchangeSymbolList: usdtSymbolList });
318
+ if (delta.addedSymbolList.length === 0 && delta.removedSymbolList.length === 0) {
319
+ this.pendingRemovalSet = new Set();
320
+ this.resolveSymbolSyncAnomaly();
321
+ return;
322
+ }
323
+ for (const symbol of delta.addedSymbolList) {
324
+ try {
325
+ const outcome = await this.ensureSymbolLoaded(symbol);
326
+ if (outcome === 'loaded') {
327
+ this.emit('symbolAdded', symbol);
328
+ }
329
+ }
330
+ catch (error) {
331
+ // symbolLoadFailed was already emitted inside ensureSymbolLoaded (feeds the digest); the
332
+ // next hourly sync retries the listing, and one bad listing must not kill the whole sync.
333
+ logger.warn({ symbol, error }, `[MarketData] ${symbol} new listing failed to load — will retry on the next sync [${this.interval}]`);
334
+ }
335
+ }
336
+ const removalDecision = evaluateSymbolRemovalList({
337
+ removedSymbolList: delta.removedSymbolList,
338
+ loadedSymbolCount,
339
+ pendingRemovalSet: this.pendingRemovalSet,
340
+ });
341
+ this.pendingRemovalSet = removalDecision.nextPendingRemovalSet;
342
+ for (const symbol of removalDecision.approvedRemovalList) {
343
+ this.emit('symbolRemoved', symbol);
344
+ this.releaseSymbol(symbol);
345
+ }
346
+ if (removalDecision.deferredRemovalList.length > 0) {
347
+ logger.error({ deferredCount: removalDecision.deferredRemovalList.length, loadedSymbolCount }, `[MarketData] Symbol sync anomaly — exchange list dropped ${removalDecision.deferredRemovalList.length}/${loadedSymbolCount} symbols; removals deferred until the next sync confirms [${this.interval}]`);
348
+ if (!this.isSymbolSyncAnomalyActive) {
349
+ this.isSymbolSyncAnomalyActive = true;
350
+ this.emit('symbolSyncAnomaly', removalDecision.deferredRemovalList.length, loadedSymbolCount);
351
+ }
352
+ }
353
+ else {
354
+ this.resolveSymbolSyncAnomaly();
355
+ }
356
+ logger.info({ addedCount: delta.addedSymbolList.length, removedCount: removalDecision.approvedRemovalList.length, deferredCount: removalDecision.deferredRemovalList.length }, `[MarketData] Symbol list synced — +${delta.addedSymbolList.length} / -${removalDecision.approvedRemovalList.length} (deferred ${removalDecision.deferredRemovalList.length}) [${this.interval}]`);
357
+ }
358
+ catch (error) {
359
+ logger.error({ error }, `[MarketData] syncAllSymbols failed [${this.interval}]`);
360
+ }
361
+ }
362
+ resolveSymbolSyncAnomaly() {
363
+ if (this.isSymbolSyncAnomalyActive) {
364
+ this.isSymbolSyncAnomalyActive = false;
365
+ logger.info({}, `[MarketData] Symbol sync anomaly cleared [${this.interval}]`);
366
+ }
367
+ }
368
+ async shutdown() {
369
+ this.emit('sourceShutdown');
370
+ this.isShutDown = true;
371
+ const exchangeClient = this.exchangeConnector.futures;
372
+ // Snapshot the footprint before the cleanup so the teardown log reports what was actually freed.
373
+ const releasedSymbolCount = this.klineListBySymbol.size;
374
+ const releasedKlineCount = this.getKlineCount();
375
+ if (this.stalenessSchedulerHandle !== null) {
376
+ this.stalenessSchedulerHandle.stop();
377
+ this.stalenessSchedulerHandle = null;
378
+ }
379
+ if (this.silenceSchedulerHandle !== null) {
380
+ this.silenceSchedulerHandle.stop();
381
+ this.silenceSchedulerHandle = null;
382
+ }
383
+ if (this.volumeSchedulerHandle !== null) {
384
+ this.volumeSchedulerHandle.stop();
385
+ this.volumeSchedulerHandle = null;
386
+ }
387
+ for (const subscription of this.subscriptionBySymbol.values()) {
388
+ try {
389
+ exchangeClient.unsubscribeKlines(subscription);
390
+ }
391
+ catch (error) {
392
+ logger.warn({ symbol: subscription.symbol, error }, `[MarketData] ${subscription.symbol} shutdown — unsubscribeKlines failed (continuing cleanup) [${this.interval}]`);
393
+ }
394
+ }
395
+ // Free every per-symbol buffer explicitly instead of trusting the whole instance to be
396
+ // garbage-collected: a backfill or gap-repair task still queued in the shared pacer keeps this
397
+ // source reachable through its closure, so without these clears the kline history (the heavy
398
+ // part — up to KLINE_BUFFER_SIZE candles per symbol) stays resident until that task drains.
399
+ this.subscriptionBySymbol.clear();
400
+ this.handlerBySymbol.clear();
401
+ this.klineListBySymbol.clear();
402
+ this.maValuesBySymbol.clear();
403
+ this.currentKlineBySymbol.clear();
404
+ this.lastEmittedUpdateBySymbol.clear();
405
+ this.consecutiveStaleScanCountBySymbol.clear();
406
+ this.persistentStaleEmittedSet.clear();
407
+ this.skippedOlderKlineCountBySymbol.clear();
408
+ this.lastEmittedVolumeBySymbol.clear();
409
+ this.lastGapRepairAtMsBySymbol.clear();
410
+ this.gapRepairInFlightSet.clear();
411
+ this.gapRepairPendingSymbolList.length = 0;
412
+ this.gapRepairFetchCountBySymbol.clear();
413
+ this.backfillConvergedSet.clear();
414
+ this.pendingBackfillConvergeSet.clear();
415
+ this.convergedReprobeCountBySymbol.clear();
416
+ this.acceptedHistoryStartMsBySymbol.clear();
417
+ this.pendingRemovalSet = new Set();
418
+ // Drop the forwarding listeners the server attached (emit('sourceShutdown') above already ran
419
+ // synchronously) so nothing external pins this dead source in memory.
420
+ this.removeAllListeners();
421
+ logger.info({ releasedSymbolCount, releasedKlineCount }, `[MarketData] Interval torn down — released ${releasedSymbolCount} symbols, ${releasedKlineCount} klines [${this.interval}]`);
422
+ }
423
+ async fetchKlinesWithTimeout(fetchPromise, timeoutMs, symbol) {
424
+ return withTimeout(fetchPromise, timeoutMs, `fetchKlines timeout after ${timeoutMs}ms for symbol ${symbol} [${this.interval}]`);
425
+ }
426
+ async loadKlines(symbolList) {
427
+ const exchangeClient = this.exchangeConnector.futures;
428
+ let loadedCount = 0;
429
+ const fetchResultList = await Promise.all(symbolList.map(async (symbol) => {
430
+ try {
431
+ // The shut-down check runs INSIDE the queue task: entries already queued behind the
432
+ // process-wide pacer must not fire their REST request after teardown.
433
+ const rawKlineList = await this.backfillQueue.execute(() => (this.isShutDown ? Promise.resolve([]) : this.fetchKlinesWithTimeout(exchangeClient.fetchKlines(symbol, this.interval, { limit: KLINE_BUFFER_SIZE }), LOAD_KLINES_TIMEOUT_MS, symbol)), `fetchKlines ${symbol} [${this.interval}]`);
434
+ const klineList = rawKlineList.slice(-KLINE_BUFFER_SIZE);
435
+ return { symbol, klineList };
436
+ }
437
+ catch (error) {
438
+ logger.warn({ symbol, error }, `[MarketData] ${symbol} Failed to load klines, skipping [${this.interval}]`);
439
+ return { symbol, klineList: [] };
440
+ }
441
+ finally {
442
+ loadedCount++;
443
+ if (loadedCount % KLINE_LOAD_PROGRESS_LOG_EVERY === 0 || loadedCount === symbolList.length) {
444
+ logger.info({ loaded: loadedCount, total: symbolList.length }, `[MarketData] Kline loading progress ${loadedCount}/${symbolList.length} [${this.interval}]`);
445
+ }
446
+ }
447
+ }));
448
+ if (this.isShutDown) {
449
+ return;
450
+ }
451
+ for (const { symbol, klineList } of fetchResultList) {
452
+ if (klineList.length === 0) {
453
+ continue;
454
+ }
455
+ // A symbol with a live handler already went live via the on-demand path while this bulk load
456
+ // was fetching — its buffer is being fed by the stream and must not be rolled back to this
457
+ // (older) REST copy. Handler presence is the discriminator on purpose: a stale buffer left by
458
+ // a previously FAILED bulk attempt has NO handler and must be overwritten.
459
+ if (this.handlerBySymbol.has(symbol)) {
460
+ continue;
461
+ }
462
+ markBackfilledHistoryClosed(klineList);
463
+ this.klineListBySymbol.set(symbol, klineList);
464
+ const lastKline = klineList[klineList.length - 1];
465
+ if (lastKline.isClosed !== true) {
466
+ this.currentKlineBySymbol.set(symbol, lastKline);
467
+ }
468
+ this.recalculateAndStoreMa(symbol, klineList);
469
+ }
470
+ }
471
+ subscribeToKlines(symbolList) {
472
+ if (this.isShutDown) {
473
+ return;
474
+ }
475
+ const exchangeClient = this.exchangeConnector.futures;
476
+ for (const symbol of symbolList) {
477
+ if (!this.klineListBySymbol.has(symbol)) {
478
+ continue;
479
+ }
480
+ if (this.handlerBySymbol.has(symbol)) {
481
+ continue;
482
+ }
483
+ const handler = (_symbol, kline) => {
484
+ this.handleKline(symbol, kline);
485
+ };
486
+ this.handlerBySymbol.set(symbol, handler);
487
+ const subscription = { symbol, interval: this.interval, handler };
488
+ this.subscriptionBySymbol.set(symbol, subscription);
489
+ exchangeClient.subscribeKlines(subscription);
490
+ if (this.lastInboundAtMs === 0) {
491
+ this.lastInboundAtMs = Date.now();
492
+ }
493
+ }
494
+ }
495
+ handleKline(symbol, kline) {
496
+ if (this.isShutDown) {
497
+ return;
498
+ }
499
+ this.recordInboundMessage();
500
+ try {
501
+ this.handleKlineUnsafe(symbol, kline);
502
+ }
503
+ catch (error) {
504
+ logger.error({ error, symbol, kline }, `[MarketData] ${symbol} handleKline threw unexpected error — swallowing to keep SDK callback loop alive [${this.interval}]`);
505
+ }
506
+ }
507
+ recordInboundMessage() {
508
+ const nowMs = Date.now();
509
+ if (this.isStreamSilent) {
510
+ const silenceMs = this.lastInboundAtMs > 0 ? nowMs - this.lastInboundAtMs : 0;
511
+ this.isStreamSilent = false;
512
+ logger.info({ silenceMs }, `[MarketData] Stream resumed after ${Math.round(silenceMs / 1000)}s of silence [${this.interval}]`);
513
+ this.emit('streamResumed', silenceMs);
514
+ }
515
+ this.lastInboundAtMs = nowMs;
516
+ }
517
+ handleKlineUnsafe(symbol, kline) {
518
+ const klineList = this.klineListBySymbol.get(symbol);
519
+ if (klineList === undefined || klineList.length === 0) {
520
+ return;
521
+ }
522
+ const lastKline = klineList[klineList.length - 1];
523
+ const isOlder = kline.openTimestamp < lastKline.openTimestamp;
524
+ if (isOlder) {
525
+ const previousCount = this.skippedOlderKlineCountBySymbol.get(symbol) ?? 0;
526
+ this.skippedOlderKlineCountBySymbol.set(symbol, previousCount + 1);
527
+ return;
528
+ }
529
+ if (this.consecutiveStaleScanCountBySymbol.has(symbol) && this.isKlineFresh(kline)) {
530
+ this.consecutiveStaleScanCountBySymbol.delete(symbol);
531
+ if (this.persistentStaleEmittedSet.delete(symbol)) {
532
+ this.emit('persistentStaleRecovered', symbol);
533
+ }
534
+ }
535
+ const isNewCandle = kline.openTimestamp > lastKline.openTimestamp;
536
+ const intervalMs = resolveIntervalMs(this.interval);
537
+ const missedCandleCount = isNewCandle ? Math.floor((kline.openTimestamp - lastKline.openTimestamp) / intervalMs) - 1 : 0;
538
+ let repairFetchCount = 0;
539
+ if (isNewCandle && lastKline.isClosed !== true) {
540
+ lastKline.isClosed = true;
541
+ // Sealed locally = the exchange's closing frame was lost; the sealed values are the last seen,
542
+ // not the true close — schedule a REST repair to correct them.
543
+ this.locallyClosedCandleCount += 1;
544
+ repairFetchCount = GAP_REPAIR_BASE_FETCH_COUNT;
545
+ const maValues = this.recalculateAndStoreMa(symbol, klineList);
546
+ this.emitGuarded('klineClosed', symbol, lastKline, maValues);
547
+ }
548
+ if (missedCandleCount > 0) {
549
+ repairFetchCount = Math.max(repairFetchCount, missedCandleCount + GAP_REPAIR_BASE_FETCH_COUNT);
550
+ }
551
+ if (repairFetchCount > 0) {
552
+ this.scheduleGapRepair(symbol, repairFetchCount);
553
+ }
554
+ if (kline.isClosed === true) {
555
+ if (isNewCandle) {
556
+ klineList.push(kline);
557
+ if (klineList.length > KLINE_BUFFER_SIZE) {
558
+ klineList.shift();
559
+ }
560
+ }
561
+ else {
562
+ klineList[klineList.length - 1] = kline;
563
+ }
564
+ this.currentKlineBySymbol.delete(symbol);
565
+ this.lastEmittedUpdateBySymbol.delete(symbol);
566
+ const maValues = this.recalculateAndStoreMa(symbol, klineList);
567
+ this.emitGuarded('klineClosed', symbol, kline, maValues);
568
+ return;
569
+ }
570
+ if (isNewCandle) {
571
+ klineList.push(kline);
572
+ if (klineList.length > KLINE_BUFFER_SIZE) {
573
+ klineList.shift();
574
+ }
575
+ }
576
+ else {
577
+ klineList[klineList.length - 1] = kline;
578
+ }
579
+ this.currentKlineBySymbol.set(symbol, kline);
580
+ const nowMs = Date.now();
581
+ const lastRecompute = this.lastEmittedUpdateBySymbol.get(symbol);
582
+ const isMaRecomputeThrottled = lastRecompute !== undefined
583
+ && lastRecompute.openTimestamp === kline.openTimestamp
584
+ && nowMs - lastRecompute.emittedAtMs < KLINE_UPDATE_THROTTLE_MS;
585
+ let maValues;
586
+ if (isMaRecomputeThrottled) {
587
+ maValues = this.maValuesBySymbol.get(symbol) ?? ZERO_MA;
588
+ this.throttledMaRecomputeCount += 1;
589
+ }
590
+ else {
591
+ maValues = this.recalculateAndStoreMa(symbol, klineList);
592
+ this.lastEmittedUpdateBySymbol.set(symbol, { openTimestamp: kline.openTimestamp, emittedAtMs: nowMs });
593
+ }
594
+ this.emitGuarded('klineUpdated', symbol, kline, maValues);
595
+ this.emitTickGuarded(symbol, kline);
596
+ }
597
+ scheduleGapRepair(symbol, fetchCount) {
598
+ if (this.isShutDown) {
599
+ return;
600
+ }
601
+ const previousFetchCount = this.gapRepairFetchCountBySymbol.get(symbol) ?? 0;
602
+ this.gapRepairFetchCountBySymbol.set(symbol, Math.min(Math.max(fetchCount, previousFetchCount), KLINE_BUFFER_SIZE));
603
+ if (this.gapRepairInFlightSet.has(symbol) || this.gapRepairPendingSymbolList.includes(symbol)) {
604
+ return;
605
+ }
606
+ const lastRepairAtMs = this.lastGapRepairAtMsBySymbol.get(symbol);
607
+ if (lastRepairAtMs !== undefined && Date.now() - lastRepairAtMs < GAP_REPAIR_COOLDOWN_MS) {
608
+ return;
609
+ }
610
+ this.gapRepairPendingSymbolList.push(symbol);
611
+ this.drainGapRepairQueue();
612
+ }
613
+ drainGapRepairQueue() {
614
+ while (!this.isShutDown && this.gapRepairInFlightSet.size < MAX_CONCURRENT_GAP_REPAIRS && this.gapRepairPendingSymbolList.length > 0) {
615
+ const symbol = this.gapRepairPendingSymbolList.shift();
616
+ this.gapRepairInFlightSet.add(symbol);
617
+ void this.runGapRepair(symbol).finally(() => {
618
+ this.gapRepairInFlightSet.delete(symbol);
619
+ this.drainGapRepairQueue();
620
+ });
621
+ }
622
+ }
623
+ async runGapRepair(symbol) {
624
+ // Released/delisted while waiting in the queue — no buffer to repair, no REST slot to burn.
625
+ if (!this.klineListBySymbol.has(symbol)) {
626
+ this.gapRepairFetchCountBySymbol.delete(symbol);
627
+ return;
628
+ }
629
+ const fetchCount = this.gapRepairFetchCountBySymbol.get(symbol) ?? GAP_REPAIR_BASE_FETCH_COUNT;
630
+ this.gapRepairFetchCountBySymbol.delete(symbol);
631
+ this.lastGapRepairAtMsBySymbol.set(symbol, Date.now());
632
+ const exchangeClient = this.exchangeConnector.futures;
633
+ try {
634
+ const rawKlineList = await this.backfillQueue.execute(() => (this.isShutDown ? Promise.resolve([]) : this.fetchKlinesWithTimeout(exchangeClient.fetchKlines(symbol, this.interval, { limit: fetchCount }), LOAD_KLINES_TIMEOUT_MS, symbol)), `gapRepair ${symbol} [${this.interval}]`);
635
+ if (this.isShutDown) {
636
+ return;
637
+ }
638
+ const bufferKlineList = this.klineListBySymbol.get(symbol);
639
+ // Released or delisted while the repair was in flight — do not resurrect the buffer.
640
+ if (bufferKlineList === undefined) {
641
+ return;
642
+ }
643
+ if (rawKlineList.length === 0) {
644
+ logger.warn({ symbol, fetchCount }, `[MarketData] ${symbol} gap repair got an empty history — the hole stays until the next detection [${this.interval}]`);
645
+ this.emit('symbolLoadFailed', symbol);
646
+ return;
647
+ }
648
+ const fetchedKlineList = rawKlineList.slice(-KLINE_BUFFER_SIZE);
649
+ markBackfilledHistoryClosed(fetchedKlineList);
650
+ const formingOpenTimestamp = this.currentKlineBySymbol.get(symbol)?.openTimestamp ?? null;
651
+ const mergeResult = mergeRepairedKlines({ bufferKlineList, fetchedKlineList, formingOpenTimestamp, maxBufferSize: KLINE_BUFFER_SIZE });
652
+ if (mergeResult.changedCount === 0) {
653
+ this.handleRepairConverged(symbol, bufferKlineList, fetchCount);
654
+ return;
655
+ }
656
+ // Data arrived — any pending convergence conclusion is stale. Dropping the converged flag
657
+ // resumes the scan's full backfills; "recovered" fires only when the history actually caught
658
+ // up (a partial merge must not read as a recovery in the operator digest).
659
+ this.pendingBackfillConvergeSet.delete(symbol);
660
+ this.convergedReprobeCountBySymbol.delete(symbol);
661
+ const wasConverged = this.backfillConvergedSet.delete(symbol);
662
+ this.klineListBySymbol.set(symbol, mergeResult.mergedKlineList);
663
+ this.recalculateAndStoreMa(symbol, mergeResult.mergedKlineList);
664
+ logger.info({ symbol, changedCount: mergeResult.changedCount, fetchCount }, `[MarketData] ${symbol} gap repair merged ${mergeResult.changedCount} candle(s) — reseeding clients [${this.interval}]`);
665
+ this.emit('symbolReseeded', symbol);
666
+ if (wasConverged && !this.isSymbolUndergrown(symbol, mergeResult.mergedKlineList)) {
667
+ this.emit('symbolBackfillRecovered', symbol);
668
+ }
669
+ }
670
+ catch (error) {
671
+ logger.error({ symbol, error }, `[MarketData] ${symbol} gap repair failed — the hole stays until the next detection [${this.interval}]`);
672
+ this.emit('symbolLoadFailed', symbol);
673
+ }
674
+ }
675
+ recalculateAndStoreMa(symbol, klineList) {
676
+ const maValues = calculateAllMaValues(klineList);
677
+ this.maValuesBySymbol.set(symbol, maValues);
678
+ return maValues;
679
+ }
680
+ isKlineFresh(kline) {
681
+ const intervalMs = resolveIntervalMs(this.interval);
682
+ const klineEndMs = kline.openTimestamp + intervalMs;
683
+ const klineAgeMs = Date.now() - klineEndMs;
684
+ const thresholdMs = STALENESS_THRESHOLD_MULTIPLIER * intervalMs;
685
+ return klineAgeMs <= thresholdMs;
686
+ }
687
+ emitTickGuarded(symbol, kline) {
688
+ if (!this.isKlineFresh(kline)) {
689
+ return;
690
+ }
691
+ this.emit('klineUpdatedTick', symbol, kline);
692
+ }
693
+ emitGuarded(eventName, symbol, kline, maValues) {
694
+ if (!this.isKlineFresh(kline)) {
695
+ this.skippedStaleEmitCount += 1;
696
+ return;
697
+ }
698
+ this.emit(eventName, symbol, kline, maValues);
699
+ }
700
+ startStalenessWatchdog() {
701
+ this.stalenessSchedulerHandle = startIntervalScheduler({
702
+ tickHandler: () => this.stalenessScan(),
703
+ intervalMs: STALENESS_CHECK_INTERVAL_MS,
704
+ contextLabel: `[MarketData] Staleness scan failed [${this.interval}]`,
705
+ heartbeatEveryNTicks: STALENESS_HEARTBEAT_EVERY_N_TICKS,
706
+ heartbeatHandler: (tickCount) => {
707
+ const symbolCount = this.klineListBySymbol.size;
708
+ const persistentStaleCount = this.consecutiveStaleScanCountBySymbol.size;
709
+ const throttledMaRecomputeCount = this.throttledMaRecomputeCount;
710
+ this.throttledMaRecomputeCount = 0;
711
+ const skippedStaleEmitCount = this.skippedStaleEmitCount;
712
+ this.skippedStaleEmitCount = 0;
713
+ const locallyClosedCandleCount = this.locallyClosedCandleCount;
714
+ this.locallyClosedCandleCount = 0;
715
+ logger.info({ tickCount, symbolCount, persistentStaleCount, throttledMaRecomputeCount, skippedStaleEmitCount, locallyClosedCandleCount }, `[MarketData] Staleness watchdog alive — tick #${tickCount}, ${symbolCount} symbols monitored, ${persistentStaleCount} persistent stale, ${throttledMaRecomputeCount} MA recomputes throttled, ${skippedStaleEmitCount} stale emits skipped, ${locallyClosedCandleCount} candles sealed locally in last period [${this.interval}]`);
716
+ this.flushSkippedOlderKlineCounters();
717
+ },
718
+ });
719
+ logger.info({ intervalMs: STALENESS_CHECK_INTERVAL_MS }, `[MarketData] Staleness watchdog started (check every ${STALENESS_CHECK_INTERVAL_MS / 1000}s) [${this.interval}]`);
720
+ }
721
+ startSilenceWatchdog() {
722
+ this.silenceSchedulerHandle = startIntervalScheduler({
723
+ tickHandler: () => this.silenceScan(),
724
+ intervalMs: SILENCE_CHECK_INTERVAL_MS,
725
+ contextLabel: `[MarketData] Silence scan failed [${this.interval}]`,
726
+ });
727
+ logger.info({ intervalMs: SILENCE_CHECK_INTERVAL_MS, thresholdMs: SILENCE_THRESHOLD_MS }, `[MarketData] Silence watchdog started (check every ${SILENCE_CHECK_INTERVAL_MS / 1000}s, threshold ${SILENCE_THRESHOLD_MS / 1000}s) [${this.interval}]`);
728
+ }
729
+ silenceScan() {
730
+ if (this.subscriptionBySymbol.size === 0 || this.lastInboundAtMs === 0) {
731
+ return;
732
+ }
733
+ const silenceMs = Date.now() - this.lastInboundAtMs;
734
+ if (silenceMs <= SILENCE_THRESHOLD_MS || this.isStreamSilent) {
735
+ return;
736
+ }
737
+ this.isStreamSilent = true;
738
+ logger.warn({ silenceMs, thresholdMs: SILENCE_THRESHOLD_MS, subscriptionCount: this.subscriptionBySymbol.size }, `[MarketData] Stream SILENT — no inbound message for ${Math.round(silenceMs / 1000)}s across ${this.subscriptionBySymbol.size} subscriptions [${this.interval}]`);
739
+ this.emit('streamSilent', silenceMs);
740
+ }
741
+ startVolumeRefreshScheduler() {
742
+ this.volumeSchedulerHandle = startIntervalScheduler({
743
+ tickHandler: () => this.refreshVolumes(),
744
+ intervalMs: VOLUME_REFRESH_INTERVAL_MS,
745
+ contextLabel: `[MarketData] Volume refresh failed [${this.interval}]`,
746
+ });
747
+ }
748
+ refreshVolumes() {
749
+ for (const symbol of this.klineListBySymbol.keys()) {
750
+ const volume24hUsdt = this.getVolume24h(symbol);
751
+ if (!Number.isFinite(volume24hUsdt)) {
752
+ continue;
753
+ }
754
+ if (this.lastEmittedVolumeBySymbol.get(symbol) === volume24hUsdt) {
755
+ continue;
756
+ }
757
+ this.lastEmittedVolumeBySymbol.set(symbol, volume24hUsdt);
758
+ this.emit('volume24h', symbol, volume24hUsdt);
759
+ }
760
+ }
761
+ stalenessScan() {
762
+ const intervalMs = resolveIntervalMs(this.interval);
763
+ const thresholdMs = STALENESS_THRESHOLD_MULTIPLIER * intervalMs;
764
+ const nowMs = Date.now();
765
+ let staleCount = 0;
766
+ for (const [symbol, klineList] of this.klineListBySymbol) {
767
+ if (klineList.length === 0) {
768
+ continue;
769
+ }
770
+ const lastKline = klineList[klineList.length - 1];
771
+ const referenceTimestamp = lastKline.closeTimestamp > 0 ? lastKline.closeTimestamp : lastKline.openTimestamp + intervalMs;
772
+ const ageMs = nowMs - referenceTimestamp;
773
+ if (ageMs <= thresholdMs) {
774
+ continue;
775
+ }
776
+ staleCount += 1;
777
+ const previousCount = this.consecutiveStaleScanCountBySymbol.get(symbol) ?? 0;
778
+ const nextCount = previousCount + 1;
779
+ this.consecutiveStaleScanCountBySymbol.set(symbol, nextCount);
780
+ if (nextCount === PERSISTENT_STALE_THRESHOLD_TICK_COUNT) {
781
+ logger.warn({ symbol, count: nextCount }, `[MarketData] ${symbol} Persistent stale threshold reached (${nextCount} consecutive stale ticks) [${this.interval}]`);
782
+ this.persistentStaleEmittedSet.add(symbol);
783
+ this.emit('persistentStaleSymbol', symbol);
784
+ }
785
+ }
786
+ if (staleCount > 0) {
787
+ logger.warn({ staleCount, totalSymbols: this.klineListBySymbol.size }, `[MarketData] Staleness scan found ${staleCount} stale symbol(s) out of ${this.klineListBySymbol.size} [${this.interval}]`);
788
+ }
789
+ this.evaluateMassStale(staleCount);
790
+ this.scanUndergrownSymbols();
791
+ }
792
+ // A symbol whose buffer is far below the history its listing time implies (a truncated load that
793
+ // would otherwise stay broken until a full restart — `computeSymbolListDelta` never re-adds an
794
+ // already-loaded symbol) is re-backfilled through the shared gap-repair queue. Converged symbols
795
+ // are left alone until their buffer changes on its own (live stream), which clears the flag.
796
+ scanUndergrownSymbols() {
797
+ for (const [symbol, klineList] of this.klineListBySymbol) {
798
+ const expectedCount = this.computeExpectedKlineCount(symbol);
799
+ // Unknown history anchor (the symbol momentarily absent from the trade-symbol cache after a
800
+ // flaky exchange list read): leave every state untouched — "unknown" must never read as
801
+ // "healthy" (a false recovery) nor as "broken" (a false backfill).
802
+ if (expectedCount === null) {
803
+ continue;
804
+ }
805
+ const isUndergrown = klineList.length + UNDERGROWN_TOLERANCE < expectedCount;
806
+ if (this.backfillConvergedSet.has(symbol)) {
807
+ if (!isUndergrown) {
808
+ this.backfillConvergedSet.delete(symbol);
809
+ this.convergedReprobeCountBySymbol.delete(symbol);
810
+ this.emit('symbolBackfillRecovered', symbol);
811
+ continue;
812
+ }
813
+ const lastRepairAtMs = this.lastGapRepairAtMsBySymbol.get(symbol) ?? 0;
814
+ if (Date.now() - lastRepairAtMs >= CONVERGED_REPROBE_INTERVAL_MS) {
815
+ this.scheduleGapRepair(symbol, KLINE_BUFFER_SIZE);
816
+ }
817
+ continue;
818
+ }
819
+ if (isUndergrown) {
820
+ this.scheduleGapRepair(symbol, KLINE_BUFFER_SIZE);
821
+ }
822
+ else {
823
+ // Live growth caught the buffer up — a stale pending vote must not combine with a much
824
+ // later convergence into a premature latch.
825
+ this.pendingBackfillConvergeSet.delete(symbol);
826
+ }
827
+ }
828
+ }
829
+ // Expected candle count from the symbol's history anchor, capped at the buffer size. The anchor
830
+ // is the listing time, overridden by an exchange-confirmed (accepted) history start when the
831
+ // symbol's short history was repeatedly reconfirmed. Returns null when neither is known (never
832
+ // seen in production for Bybit/Binance, which always populate the listing time) — an unknown
833
+ // expectation must not flag a symbol, so the scan skips it.
834
+ computeExpectedKlineCount(symbol) {
835
+ const tradeSymbolBySymbol = this.exchangeConnector.futures.tradeSymbols;
836
+ const launchTimestamp = tradeSymbolBySymbol?.get(symbol)?.launchTimestamp;
837
+ const isLaunchKnown = launchTimestamp !== undefined && Number.isFinite(launchTimestamp) && launchTimestamp > 0;
838
+ const acceptedStartMs = this.acceptedHistoryStartMsBySymbol.get(symbol);
839
+ let anchorMs = isLaunchKnown ? launchTimestamp : undefined;
840
+ if (acceptedStartMs !== undefined && (anchorMs === undefined || acceptedStartMs > anchorMs)) {
841
+ anchorMs = acceptedStartMs;
842
+ }
843
+ if (anchorMs === undefined) {
844
+ return null;
845
+ }
846
+ const elapsedMs = Date.now() - anchorMs;
847
+ if (elapsedMs <= 0) {
848
+ return 0;
849
+ }
850
+ return Math.min(KLINE_BUFFER_SIZE, Math.floor(elapsedMs / resolveIntervalMs(this.interval)));
851
+ }
852
+ isSymbolUndergrown(symbol, klineList) {
853
+ const expectedCount = this.computeExpectedKlineCount(symbol);
854
+ if (expectedCount === null) {
855
+ return false;
856
+ }
857
+ return klineList.length + UNDERGROWN_TOLERANCE < expectedCount;
858
+ }
859
+ // A repair fetched fresh data yet merged nothing. Only a FULL-window request may argue the
860
+ // exchange has nothing more (a 2-candle seal/hole repair returning known candles proves nothing
861
+ // about the missing depth), and even a full window must converge twice in a row before the latch.
862
+ handleRepairConverged(symbol, bufferKlineList, fetchCount) {
863
+ if (fetchCount < KLINE_BUFFER_SIZE) {
864
+ return;
865
+ }
866
+ if (!this.isSymbolUndergrown(symbol, bufferKlineList)) {
867
+ this.pendingBackfillConvergeSet.delete(symbol);
868
+ return;
869
+ }
870
+ if (this.backfillConvergedSet.has(symbol)) {
871
+ this.recordConvergedReprobe(symbol, bufferKlineList);
872
+ return;
873
+ }
874
+ if (!this.pendingBackfillConvergeSet.has(symbol)) {
875
+ this.pendingBackfillConvergeSet.add(symbol);
876
+ return;
877
+ }
878
+ this.pendingBackfillConvergeSet.delete(symbol);
879
+ this.backfillConvergedSet.add(symbol);
880
+ logger.warn({ symbol, bufferSize: bufferKlineList.length }, `[MarketData] ${symbol} backfill converged below its expected history twice in a row — the exchange serves no more candles [${this.interval}]`);
881
+ this.emit('symbolBackfillStuck', symbol);
882
+ }
883
+ // An hourly re-probe of a latched symbol reconfirmed the convergence. After enough consecutive
884
+ // reconfirmations the short history is accepted as the exchange's truth: the expectation is
885
+ // re-anchored at the symbol's own first candle and the symbol leaves the digest.
886
+ recordConvergedReprobe(symbol, bufferKlineList) {
887
+ const reprobeCount = (this.convergedReprobeCountBySymbol.get(symbol) ?? 0) + 1;
888
+ if (reprobeCount < CONVERGED_ACCEPT_REPROBE_COUNT) {
889
+ this.convergedReprobeCountBySymbol.set(symbol, reprobeCount);
890
+ return;
891
+ }
892
+ const historyStartMs = bufferKlineList[0].openTimestamp;
893
+ this.acceptedHistoryStartMsBySymbol.set(symbol, historyStartMs);
894
+ this.backfillConvergedSet.delete(symbol);
895
+ this.convergedReprobeCountBySymbol.delete(symbol);
896
+ logger.info({ symbol, historyStartMs, bufferSize: bufferKlineList.length }, `[MarketData] ${symbol} short history accepted as the exchange's truth after ${CONVERGED_ACCEPT_REPROBE_COUNT} reconfirming re-probes — history starts at ${new Date(historyStartMs).toISOString()} [${this.interval}]`);
897
+ this.emit('symbolBackfillRecovered', symbol);
898
+ }
899
+ evaluateMassStale(staleCount) {
900
+ const symbolCount = this.klineListBySymbol.size;
901
+ const isMassStaleNow = crossesMassStaleThreshold({ staleCount, symbolCount, ratioThreshold: MASS_STALE_RATIO_THRESHOLD, minSymbols: MASS_STALE_MIN_SYMBOLS });
902
+ if (isMassStaleNow && !this.isMassStale) {
903
+ this.isMassStale = true;
904
+ logger.warn({ staleCount, symbolCount }, `[MarketData] MASS STALE — ${staleCount}/${symbolCount} symbols stale, escalating as a whole-source degradation [${this.interval}]`);
905
+ this.emit('sourceMassStale', staleCount, symbolCount);
906
+ return;
907
+ }
908
+ if (!isMassStaleNow && this.isMassStale) {
909
+ this.isMassStale = false;
910
+ logger.info({ staleCount, symbolCount }, `[MarketData] Mass stale recovered — ${staleCount}/${symbolCount} symbols stale [${this.interval}]`);
911
+ this.emit('sourceMassStaleRecovered', staleCount, symbolCount);
912
+ }
913
+ }
914
+ flushSkippedOlderKlineCounters() {
915
+ if (this.skippedOlderKlineCountBySymbol.size === 0) {
916
+ return;
917
+ }
918
+ const countBySymbol = {};
919
+ let totalCount = 0;
920
+ for (const [symbol, count] of this.skippedOlderKlineCountBySymbol) {
921
+ countBySymbol[symbol] = count;
922
+ totalCount += count;
923
+ }
924
+ const symbolCount = this.skippedOlderKlineCountBySymbol.size;
925
+ logger.info({ countBySymbol, totalCount, symbolCount }, `[MarketData] Skipped replayed older klines since last heartbeat: ${totalCount} total across ${symbolCount} symbol(s) [${this.interval}]`);
926
+ this.skippedOlderKlineCountBySymbol.clear();
927
+ }
928
+ }
929
+ export { MarketDataManager };
930
+ //# sourceMappingURL=marketDataManager.js.map