Skip to main content

rustrade_execution/client/
dedup.rs

1//! Account-event deduplication, shared across execution clients.
2//!
3//! Every client that can be served the same fill twice needs this. Two situations produce a
4//! duplicate, and both are ordinary rather than exceptional:
5//!
6//! - **A subscription replays on reconnect.** A venue that opens a stream with a snapshot of
7//!   recent activity re-delivers everything in that snapshot each time the socket comes back.
8//! - **Recovery overlaps the stream.** A client that refetches fills over a lookback window after
9//!   a gap will refetch some the stream already delivered, because the window is deliberately
10//!   wider than the gap.
11//!
12//! The cache is keyed on (instrument, id, kind) rather than on the id alone, and `DedupKey`
13//! documents why. It is bounded and LRU, so it degrades by forgetting the oldest event rather
14//! than by growing without limit.
15//!
16//! This module is exchange-agnostic: it operates on rustrade's own
17//! [`UnindexedAccountEvent`], never on any SDK's wire types.
18
19use crate::{
20    AccountEventKind, UnindexedAccountEvent, margin_debt::MarginDebtKind, order::state::OrderState,
21};
22use lru::LruCache;
23use rust_decimal::Decimal;
24use smol_str::SmolStr;
25use std::{num::NonZeroUsize, sync::Arc};
26
27/// Size of the LRU dedup cache. 10k entries covers ~hours of high-frequency
28/// trading at typical fill rates; each entry is ~100-104 bytes (`DedupKey` =
29/// `SmolStr` 24 + `SmolStr` 24 + `DedupEventKind` 24 (8-aligned by `Notice`'s `i64`) = 72
30/// bytes, plus
31/// `LruCache` node overhead: 2 linked-list pointers 16 + hashbrown slot ~12-16
32/// ≈ 28-32 bytes). At 10k: ~1.0 MB.
33/// At very high fill rates (>333 distinct fills/sec sustained during the 30s
34/// recovery window), LRU eviction could allow a fill to pass dedup twice.
35/// Increase this constant if such volumes are expected.
36pub(crate) const DEDUP_CACHE_SIZE: usize = 10_000;
37
38/// Event kind discriminant for dedup keys. Using an enum instead of a SmolStr
39/// constant avoids constructing a string value on every event in the hot path.
40#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
41pub(crate) enum DedupEventKind {
42    Trade,
43    /// An order-state snapshot, carrying the order's cumulative filled quantity.
44    ///
45    /// The quantity is part of the discriminant because one order produces a succession of
46    /// these and they are not interchangeable: the acknowledgement and every fill that follows
47    /// it all describe the same `OrderId` in a different state. Keyed on the id alone, the
48    /// acknowledgement swallows every later snapshot for that order, and `Open::filled_quantity`
49    /// never advances past the `0` the acknowledgement carried.
50    ///
51    /// Replay suppression is unaffected, because a re-delivered snapshot carries the quantity it
52    /// carried the first time and so still collides. That matters beyond saving work: a replayed
53    /// acknowledgement for an order that has since retired is a partially-filled `Open` snapshot
54    /// for an untracked order, which `OrderManager`'s vacant-entry arm inserts as a live resting
55    /// order. Suppressing the replay is what keeps that order retired.
56    ///
57    /// `Decimal`'s `Hash` normalises before hashing and its `Eq` is numeric, so `1.0` and `1.00`
58    /// are the same key — the representation a venue happens to send does not split it.
59    OrderState {
60        filled_quantity: Decimal,
61    },
62    Cancelled,
63    /// A funding payment, keyed on its instrument and funding time because the venue gives it no
64    /// id. Only Hyperliquid's funding stream produces one, so it exists only with that client.
65    #[cfg(feature = "hyperliquid")]
66    Funding,
67    /// An account notice, keyed on its instrument (empty for the account as a whole), the venue's
68    /// status and the time the venue sent it, since a notice has no id. A venue that sends one
69    /// notice twice, such as on two streams, sends it with the same time, so the second is
70    /// dropped.
71    Notice {
72        time_exchange_ms: i64,
73    },
74    /// A margin debt update, keyed on its instrument (empty for the account as a whole), its asset,
75    /// the time the venue made the change, what changed it and the totals after it, since an update
76    /// has no id. A venue that sends one update twice, such as on two streams, sends it with the
77    /// same time and totals, so the second is dropped.
78    ///
79    /// The kind and totals are part of the discriminant because two changes to one asset can share
80    /// a millisecond, such as a repay that settles several loans at once. Each states the debt
81    /// after it, so dropping the second would leave the earlier total held. `Decimal`'s `Hash`
82    /// and `Eq` are numeric, as for [`OrderState`](Self::OrderState).
83    MarginDebt {
84        time_exchange_ms: i64,
85        kind: MarginDebtKind,
86        borrowed: Decimal,
87        interest: Decimal,
88    },
89}
90
91/// Dedup cache key: (instrument, event ID, event kind).
92///
93/// Using separate `SmolStr` fields avoids the `format!("{}:{}", ...)` construction
94/// and the heap allocation when a combined string would exceed SmolStr's 23-byte
95/// inline limit. Both fields are already `SmolStr` values — no allocation needed.
96///
97/// - For TRADE events: instrument + trade_id + `DedupEventKind::Trade`
98/// - For order snapshots: instrument + order_id + `DedupEventKind::OrderState`, which carries the
99///   cumulative filled quantity so that an acknowledgement and the fills after it stay distinct
100/// - For CANCELED/EXPIRED: instrument + order_id + `DedupEventKind::Cancelled`
101/// - For Hyperliquid funding payments: coin + funding time in milliseconds +
102///   `DedupEventKind::Funding`, since a payment has no id
103/// - For account notices: instrument (empty when the notice names none) + the venue's status +
104///   `DedupEventKind::Notice`, carrying the time the venue sent it
105/// - For margin debt updates: instrument (empty when the update names none) + the asset +
106///   `DedupEventKind::MarginDebt`, carrying the time the venue made the change, its kind and the
107///   totals after it
108#[derive(Debug, Hash, Eq, PartialEq)]
109pub(crate) struct DedupKey {
110    pub(crate) instrument: SmolStr,
111    pub(crate) id: SmolStr,
112    pub(crate) kind: DedupEventKind,
113}
114pub(crate) type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<DedupKey, ()>>>;
115
116pub(crate) fn new_dedup_cache() -> SharedDedupCache {
117    // allow(clippy::unwrap_used) — NonZeroUsize::new on a literal constant
118    // cannot fail at runtime.
119    #[allow(clippy::unwrap_used)]
120    Arc::new(parking_lot::Mutex::new(LruCache::new(
121        NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
122    )))
123}
124
125/// Extract a dedup key from an account event, if applicable.
126/// Returns None for events that don't need deduplication (e.g. balance snapshots).
127pub(crate) fn dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<DedupKey> {
128    // The instrument is part of the key because venue ids are routinely per-symbol rather
129    // than global — Binance trade and order ids are, and Hyperliquid's own documentation
130    // says `tid` should be qualified by coin rather than treated as globally unique. Without
131    // it, BTCUSDT trade 9001 and ETHUSDT trade 9001 collide during a multi-symbol recovery.
132    match &event.kind {
133        AccountEventKind::Trade(trade) => Some(DedupKey {
134            instrument: trade.instrument.name().clone(),
135            id: trade.id.0.clone(),
136            kind: DedupEventKind::Trade,
137        }),
138        AccountEventKind::OrderSnapshot(snap) => {
139            // OrderSnapshot wraps Order<..., OrderState<...>>. An acknowledgement and a fill both
140            // arrive here as Active(Open { .. }), distinguished only by `filled_quantity` -- so
141            // that field is part of the key rather than a detail the key discards.
142            match &snap.0.state {
143                OrderState::Active(active) => {
144                    // ActiveOrderState variants: OpenInFlight, Open, CancelInFlight
145                    use crate::order::state::ActiveOrderState;
146                    match active {
147                        ActiveOrderState::Open(open) => Some(DedupKey {
148                            instrument: snap.0.key.instrument.name().clone(),
149                            // A venue that named the order gives the key; one that did not leaves
150                            // the client id, which is stable across re-deliveries for the same
151                            // reason the venue id is.
152                            id: match open.id.assigned() {
153                                Some(id) => id.0.clone(),
154                                None => snap.0.key.cid.0.clone(),
155                            },
156                            kind: DedupEventKind::OrderState {
157                                filled_quantity: open.filled_quantity,
158                            },
159                        }),
160                        _ => None,
161                    }
162                }
163                _ => None,
164            }
165        }
166        AccountEventKind::OrderCancelled(resp) => match &resp.state {
167            Ok(cancelled) => Some(DedupKey {
168                instrument: resp.key.instrument.name().clone(),
169                id: cancelled.id.0.clone(),
170                kind: DedupEventKind::Cancelled,
171            }),
172            Err(_) => None, // error responses don't need dedup
173        },
174        // FillRecoveryGaveUp has no key either: two give-ups of one span are two failed reads.
175        // TradeAmended has none. Alpaca's amendments are not deduplicated: whether Alpaca can send
176        // one twice is unknown, and TradeAmendment tells the consumer to apply it idempotently.
177        // IBKR's EventSink tracks each execution's revisions itself, which also keeps an
178        // execution from going out after its correction.
179        AccountEventKind::Notice(notice) => Some(DedupKey {
180            instrument: notice
181                .instrument
182                .as_ref()
183                .map(|instrument| instrument.name().clone())
184                .unwrap_or_default(),
185            id: notice.status.clone(),
186            kind: DedupEventKind::Notice {
187                time_exchange_ms: notice.time_exchange.timestamp_millis(),
188            },
189        }),
190        AccountEventKind::MarginDebt(update) => Some(DedupKey {
191            instrument: update
192                .instrument
193                .as_ref()
194                .map(|instrument| instrument.name().clone())
195                .unwrap_or_default(),
196            id: update.asset.name().clone(),
197            kind: DedupEventKind::MarginDebt {
198                time_exchange_ms: update.time_exchange.timestamp_millis(),
199                kind: update.kind,
200                borrowed: update.debt.borrowed,
201                interest: update.debt.interest,
202            },
203        }),
204        // CashFlow has none here: a venue may give a flow no id, and what identifies one then is
205        // the producer's to say, so each producer keys its own (Hyperliquid's funding on coin and
206        // time).
207        _ => None, // BalanceSnapshot, BalanceStreamUpdate, InstrumentBalanceUpdate, InstrumentBalanceSnapshot, Snapshot, StreamTerminated, FillRecoveryGaveUp, TradeAmended, ReinitFailed, CashFlow — no dedup needed
208    }
209}
210
211/// Check and insert a dedup key. Returns true if the event is a duplicate.
212/// Takes `key` by value to avoid cloning on the non-duplicate (common) path.
213pub(crate) fn is_duplicate(cache: &SharedDedupCache, key: DedupKey) -> bool {
214    // parking_lot::Mutex — never poisons (if a prior callback panicked, the mutex
215    // auto-unlocks cleanly). Blocking in an async context is acceptable here: the lock is
216    // held for two hash ops on a bounded LRU cache (~microseconds), so a stalled worker
217    // thread is negligible even under the worst contention a client produces — Binance's
218    // fill recovery runs `buffer_unordered(8)` against it while the WS callback task is
219    // live, and is itself bounded by a recovery timeout.
220    //
221    // Note: with `worker_threads = 1` those concurrent recovery tasks all block on this
222    // mutex sequentially; upgrade to `tokio::sync::Mutex` if a single-worker runtime is
223    // required.
224    let mut guard = cache.lock();
225    // peek avoids promoting the duplicate to MRU position (we're about to
226    // discard it anyway), saving a linked-list move on the early-exit path.
227    if guard.peek(&key).is_some() {
228        return true;
229    }
230    guard.put(key, ());
231    false
232}
233
234/// A shared, bounded record of the fills already delivered, for a consumer that takes the same
235/// fill from more than one source.
236///
237/// Fills reach a consumer by more than one path: the live stream, a read of the span the stream
238/// missed (see [`ExecutionClient::fetch_trades`](crate::client::ExecutionClient::fetch_trades)),
239/// and a replay when a stream reopens. Their spans overlap on purpose, so the same fill arrives
240/// twice. [`is_duplicate`](Self::is_duplicate) says whether a [`Trade`](crate::AccountEventKind::Trade)
241/// event is one already seen, keyed by instrument and [`TradeId`](crate::trade::TradeId):
242/// whichever path delivered it, a fill has one id.
243///
244/// Only fills are recorded. An order snapshot, a cancellation or a notice can legitimately repeat
245/// (an order amended without a new fill is a second `Open` snapshot with the same quantity), so
246/// each is always passed on.
247///
248/// Clones share one record, which holds the 10,000 most recent keys and forgets the oldest first.
249///
250/// Call [`is_duplicate`](Self::is_duplicate) when the event is delivered, not when it is read: it
251/// records the event as seen, so an event read but never delivered would be dropped on its retry.
252#[derive(Debug, Clone)]
253pub struct AccountEventDeduper(SharedDedupCache);
254
255impl Default for AccountEventDeduper {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl AccountEventDeduper {
262    /// An empty record.
263    pub fn new() -> Self {
264        Self(new_dedup_cache())
265    }
266
267    /// Whether `event` is a fill already seen. Records it as seen when it was not. Always `false`
268    /// for an event that is not a fill.
269    pub fn is_duplicate(&self, event: &UnindexedAccountEvent) -> bool {
270        matches!(event.kind, AccountEventKind::Trade(_))
271            && dedup_key_from_event(event).is_some_and(|key| is_duplicate(&self.0, key))
272    }
273
274    /// Records `event` as seen if it is a fill, without asking whether it was: for a fill
275    /// delivered by a path that is not itself deduplicated, so that a later copy of it is.
276    pub fn record(&self, event: &UnindexedAccountEvent) {
277        if matches!(event.kind, AccountEventKind::Trade(_))
278            && let Some(key) = dedup_key_from_event(event)
279        {
280            self.0.lock().put(key, ());
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::{
289        order::id::{OrderId, StrategyId},
290        trade::{AssetFees, Trade, TradeId},
291    };
292    use chrono::DateTime;
293    use rust_decimal::Decimal;
294    use rustrade_instrument::{
295        Side, asset::name::AssetNameExchange, exchange::ExchangeId,
296        instrument::name::InstrumentNameExchange,
297    };
298
299    fn trade_event(instrument: &str, id: &str) -> UnindexedAccountEvent {
300        UnindexedAccountEvent {
301            exchange: ExchangeId::BinanceSpot,
302            kind: AccountEventKind::Trade(Trade::new(
303                TradeId::new(id),
304                OrderId::new("o"),
305                InstrumentNameExchange::new(instrument),
306                StrategyId::unknown(),
307                DateTime::UNIX_EPOCH,
308                Side::Buy,
309                Decimal::ONE,
310                Decimal::ONE,
311                None,
312                AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
313            )),
314        }
315    }
316
317    #[test]
318    fn a_trade_is_a_duplicate_the_second_time_it_is_seen_by_instrument_and_id() {
319        let deduper = AccountEventDeduper::new();
320
321        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
322        assert!(deduper.is_duplicate(&trade_event("btc_usdt", "1")));
323        // The id is per instrument.
324        assert!(!deduper.is_duplicate(&trade_event("eth_usdt", "1")));
325        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "2")));
326    }
327
328    /// A recorded fill is a duplicate when it is seen next, and recording one already seen
329    /// changes nothing. An event that is not a fill is not recorded, even one with a key.
330    #[test]
331    fn a_recorded_fill_is_a_duplicate_and_nothing_else_is_recorded() {
332        let deduper = AccountEventDeduper::new();
333
334        deduper.record(&trade_event("btc_usdt", "1"));
335        deduper.record(&trade_event("btc_usdt", "1"));
336        assert!(deduper.is_duplicate(&trade_event("btc_usdt", "1")));
337        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "2")));
338
339        let notice = UnindexedAccountEvent {
340            exchange: ExchangeId::BinanceSpot,
341            kind: AccountEventKind::Notice(crate::notice::AccountNotice::new(
342                crate::notice::NoticeKind::Other,
343                None,
344                DateTime::UNIX_EPOCH,
345                None,
346                "STATUS".into(),
347            )),
348        };
349        assert!(dedup_key_from_event(&notice).is_some());
350        deduper.record(&notice);
351        assert_eq!(deduper.0.lock().len(), 2);
352    }
353
354    #[test]
355    fn clones_share_one_record() {
356        let deduper = AccountEventDeduper::new();
357        let clone = deduper.clone();
358
359        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
360
361        assert!(clone.is_duplicate(&trade_event("btc_usdt", "1")));
362    }
363
364    #[test]
365    fn an_event_that_is_not_a_fill_is_never_a_duplicate() {
366        let deduper = AccountEventDeduper::new();
367        let event = UnindexedAccountEvent {
368            exchange: ExchangeId::BinanceSpot,
369            kind: AccountEventKind::StreamTerminated(
370                crate::error::StreamTerminationReason::RateLimited {
371                    retry_after: std::time::Duration::from_secs(1),
372                },
373            ),
374        };
375
376        assert!(!deduper.is_duplicate(&event));
377        assert!(!deduper.is_duplicate(&event));
378    }
379
380    #[test]
381    fn a_repeated_notice_is_passed_on_because_only_fills_are_recorded() {
382        let deduper = AccountEventDeduper::new();
383        let notice = || UnindexedAccountEvent {
384            exchange: ExchangeId::BinanceSpot,
385            kind: AccountEventKind::Notice(crate::notice::AccountNotice::new(
386                crate::notice::NoticeKind::Other,
387                None,
388                DateTime::UNIX_EPOCH,
389                None,
390                "STATUS".into(),
391            )),
392        };
393
394        // The client-level dedup would treat the second as a duplicate.
395        assert!(dedup_key_from_event(&notice()).is_some());
396        assert!(!deduper.is_duplicate(&notice()));
397        assert!(!deduper.is_duplicate(&notice()));
398    }
399
400    /// A copy of a debt update keys alike, but a second change to the same asset in the same
401    /// millisecond does not: it states a different debt, which must reach the consumer.
402    #[test]
403    fn only_a_copy_of_a_margin_debt_update_keys_alike() {
404        use crate::{
405            balance::MarginDetails,
406            margin_debt::{MarginDebtKind, MarginDebtUpdate},
407        };
408
409        let update = |kind, borrowed: i64, interest: i64| {
410            dedup_key_from_event(&UnindexedAccountEvent {
411                exchange: ExchangeId::BinanceMargin,
412                kind: AccountEventKind::MarginDebt(MarginDebtUpdate::new(
413                    AssetNameExchange::new("BTC"),
414                    None,
415                    MarginDetails::new(Decimal::new(borrowed, 8), Decimal::new(interest, 8)),
416                    kind,
417                    "VENUE".into(),
418                    DateTime::UNIX_EPOCH,
419                )),
420            })
421        };
422
423        assert!(update(MarginDebtKind::Repay, 6_000, 2).is_some());
424        assert_eq!(
425            update(MarginDebtKind::Repay, 6_000, 2),
426            update(MarginDebtKind::Repay, 6_000, 2)
427        );
428        assert_ne!(
429            update(MarginDebtKind::Repay, 6_000, 2),
430            update(MarginDebtKind::Repay, 3_000, 2)
431        );
432        assert_ne!(
433            update(MarginDebtKind::Repay, 6_000, 2),
434            update(MarginDebtKind::Repay, 6_000, 0)
435        );
436        assert_ne!(
437            update(MarginDebtKind::Repay, 6_000, 2),
438            update(MarginDebtKind::Interest, 6_000, 2)
439        );
440    }
441}