watchTradesForSymbols over-delivers repeated trades on multi-symbol subscriptions · Issue #30474 · ccxt/ccxt · GitHub
Skip to content

watchTradesForSymbols over-delivers repeated trades on multi-symbol subscriptions #30474

Open

Description

@alert101

Operating System

Windows 11 x64

Programming Language

C#

CCXT Version

4.5.77

Description

Affected file(s)

  • ts/src/base/ws/Cache.ts (ArrayCache.getLimit)
  • 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

  1. Call watchTradesForSymbols([...manySymbols]) on any exchange that subscribes many symbols onto one shared ArrayCache trades cache.
  2. Let trades arrive continuously for all subscribed symbols for several minutes.
  3. 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:

if (this.newUpdates) {
    const first = this.safeValue (trades, 0);
    const tradeSymbol = this.safeString (first, 'symbol');
    limit = trades.getLimit (tradeSymbol, limit);
}
return this.filterBySinceLimit (trades, since, limit, 'timestamp', true);

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.

Activity

  1. carlotestor commented on Sep 16, 2026

    @carlotestor
    Collaborator

    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:

    1. this.safeValue (trades, 0) on an empty/evicted cache gives tradeSymbol === undefined, which silently falls into the global allNewUpdates branch of ArrayCache.getLimit (ts/src/base/ws/Cache.ts:96-99) — the opposite scope from the one intended.
    2. 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.

  2. alert101 commented on Sep 26, 2026

    @alert101
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugwsWebSocket (aka ccxt.pro)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions