You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
watchTradesForSymbols over-delivers repeated trades on multi-symbol subscriptions #30474
Every exchange's watchTradesForSymbols implementation that resolves limit via trades.getLimit(<symbol of trades[0]>, limit), for example ts/src/pro/binance.ts, ts/src/pro/okx.ts, ts/src/pro/bybit.ts, ts/src/pro/bitmex.ts, ts/src/pro/aster.ts, ts/src/pro/bitget.ts, ts/src/pro/upbit.ts, ts/src/pro/coinex.ts, and others implementing the same pattern.
Steps to reproduce
Call watchTradesForSymbols([...manySymbols]) on any exchange that subscribes many symbols onto one shared ArrayCache trades cache.
Let trades arrive continuously for all subscribed symbols for several minutes.
Compare the number of trades returned across repeated resolutions of the same watchTradesForSymbols call against the number of genuinely new trades observed on the exchange for that period.
Expected behavior
Each call to watchTradesForSymbols should return only the trades that are new since the caller's previous resolution, across all subscribed symbols, with no repeats.
Root cause
watchTradesForSymbols resolves its return-size limit as:
trades is one ArrayCache shared by every symbol the caller subscribed to. ArrayCache.getLimit(symbol, limit) reads and clears the new-update counter for only the symbol of trades[0] (an arbitrary, typically stale row — whatever currently happens to sit at index 0 of the shared cache). Every other subscribed symbol's newUpdatesBySymbol[symbol] counter keeps incrementing on every append() and is never cleared, because nothing ever calls getLimit with those symbols. The counter for those symbols grows without bound across many resolutions, so the limit used to tail-slice the shared array keeps growing, and previously-returned trades for those symbols are included again on the next call. The amplification factor tracks how unevenly getLimit happens to land on one symbol versus the rest of the subscribed set.
Suggested fix
Resolve the multi-symbol case against the cache's global/all-symbols scope instead of a single symbol's scope, e.g.:
limit=trades.getLimit(undefined,limit);
ArrayCache already tracks a global allNewUpdates counter (incremented on every append() regardless of symbol, cleared only when getLimit(undefined, ...) is called) that is independent of any single symbol's counter, so this correctly returns "every trade new since this caller's last resolution" across the whole subscribed set with no unbounded growth. This is a one-line change per affected watchTradesForSymbols implementation.
Related
Already reported this under #29667 but that issue covers only Lighter exchange so decided to open a new issue as this affects multiple exchanges.
Thanks for the detailed writeup — the getLimit (tradeSymbol, limit) pattern you point at is real and appears in ~29 ts/src/pro/*.ts files.
One thing that needs pinning down before a fix, though: the stated root cause assumes trades is one ArrayCache shared by every subscribed symbol. In the implementations you list, that does not look like what happens — each symbol gets its own cache, keyed in this.trades[symbol]:
ts/src/pro/binance.ts:1606-1613 (handleTrade) — this.trades[symbol] = tradesArray (one ArrayCache per symbol)
ts/src/pro/upbit.ts:312-316, ts/src/pro/okx.ts:352, ts/src/pro/bybit.ts:1216, ts/src/pro/bitget.ts:1143, ts/src/pro/bitmex.ts:680 — same shape
watchMultiple resolves the message hash of whichever symbol just received a trade, so the object returned to watchTradesForSymbols is that single symbol's cache; trades[0] is a row of that same cache, and tradeSymbol therefore normally equals the cache's own symbol. On that reading getLimit (undefined, limit) would be the wrong change — it would swap a per-symbol counter for the global one on a cache that only ever holds one symbol.
That does leave two real edge cases in the current code, which may be what you are observing:
this.safeValue (trades, 0) on an empty/evicted cache gives tradeSymbol === undefined, which silently falls into the globalallNewUpdates branch of ArrayCache.getLimit (ts/src/base/ws/Cache.ts:96-99) — the opposite scope from the one intended.
Exchanges that genuinely do merge symbols into one cache would behave exactly as you describe.
Could you help narrow it down:
Which exchange id and which symbols reproduced it for you (the C# 4.5.77 run), and roughly how many resolutions before the duplication was visible?
A minimal snippet plus a count of returned-vs-new trades per resolution, ideally logging trades.Count and the symbols present in the returned batch — if a single returned batch contains more than one symbol, that confirms the shared-cache premise for that exchange and makes this trivially actionable.
With a confirmed exchange id where one batch spans several symbols, the one-line fix you propose is very likely right for that implementation.
The fix posted by @Tyagiquamar is valid but doesn't solve the actual problem here, which is still unknown. I'm still trying to pinpoint the actual root cause. Will report back after more testing.
Activity
carlotestor commentedon Sep 16, 2026
Thanks for the detailed writeup — the
getLimit (tradeSymbol, limit)pattern you point at is real and appears in ~29ts/src/pro/*.tsfiles.One thing that needs pinning down before a fix, though: the stated root cause assumes
tradesis oneArrayCacheshared by every subscribed symbol. In the implementations you list, that does not look like what happens — each symbol gets its own cache, keyed inthis.trades[symbol]:ts/src/pro/binance.ts:1606-1613(handleTrade) —this.trades[symbol] = tradesArray(oneArrayCacheper symbol)ts/src/pro/upbit.ts:312-316,ts/src/pro/okx.ts:352,ts/src/pro/bybit.ts:1216,ts/src/pro/bitget.ts:1143,ts/src/pro/bitmex.ts:680— same shapewatchMultipleresolves the message hash of whichever symbol just received a trade, so the object returned towatchTradesForSymbolsis that single symbol's cache;trades[0]is a row of that same cache, andtradeSymboltherefore normally equals the cache's own symbol. On that readinggetLimit (undefined, limit)would be the wrong change — it would swap a per-symbol counter for the global one on a cache that only ever holds one symbol.That does leave two real edge cases in the current code, which may be what you are observing:
this.safeValue (trades, 0)on an empty/evicted cache givestradeSymbol === undefined, which silently falls into the globalallNewUpdatesbranch ofArrayCache.getLimit(ts/src/base/ws/Cache.ts:96-99) — the opposite scope from the one intended.Could you help narrow it down:
trades.Countand the symbols present in the returned batch — if a single returned batch contains more than one symbol, that confirms the shared-cache premise for that exchange and makes this trivially actionable.With a confirmed exchange id where one batch spans several symbols, the one-line fix you propose is very likely right for that implementation.
alert101 commentedon Sep 26, 2026
The initial bug report might have been wrong.
The fix posted by @Tyagiquamar is valid but doesn't solve the actual problem here, which is still unknown. I'm still trying to pinpoint the actual root cause. Will report back after more testing.