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
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::{
279        order::id::{OrderId, StrategyId},
280        trade::{AssetFees, Trade, TradeId},
281    };
282    use chrono::DateTime;
283    use rust_decimal::Decimal;
284    use rustrade_instrument::{
285        Side, asset::name::AssetNameExchange, exchange::ExchangeId,
286        instrument::name::InstrumentNameExchange,
287    };
288
289    fn trade_event(instrument: &str, id: &str) -> UnindexedAccountEvent {
290        UnindexedAccountEvent {
291            exchange: ExchangeId::BinanceSpot,
292            kind: AccountEventKind::Trade(Trade::new(
293                TradeId::new(id),
294                OrderId::new("o"),
295                InstrumentNameExchange::new(instrument),
296                StrategyId::unknown(),
297                DateTime::UNIX_EPOCH,
298                Side::Buy,
299                Decimal::ONE,
300                Decimal::ONE,
301                None,
302                AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
303            )),
304        }
305    }
306
307    #[test]
308    fn a_trade_is_a_duplicate_the_second_time_it_is_seen_by_instrument_and_id() {
309        let deduper = AccountEventDeduper::new();
310
311        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
312        assert!(deduper.is_duplicate(&trade_event("btc_usdt", "1")));
313        // The id is per instrument.
314        assert!(!deduper.is_duplicate(&trade_event("eth_usdt", "1")));
315        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "2")));
316    }
317
318    #[test]
319    fn clones_share_one_record() {
320        let deduper = AccountEventDeduper::new();
321        let clone = deduper.clone();
322
323        assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
324
325        assert!(clone.is_duplicate(&trade_event("btc_usdt", "1")));
326    }
327
328    #[test]
329    fn an_event_that_is_not_a_fill_is_never_a_duplicate() {
330        let deduper = AccountEventDeduper::new();
331        let event = UnindexedAccountEvent {
332            exchange: ExchangeId::BinanceSpot,
333            kind: AccountEventKind::StreamTerminated(
334                crate::error::StreamTerminationReason::RateLimited {
335                    retry_after: std::time::Duration::from_secs(1),
336                },
337            ),
338        };
339
340        assert!(!deduper.is_duplicate(&event));
341        assert!(!deduper.is_duplicate(&event));
342    }
343
344    #[test]
345    fn a_repeated_notice_is_passed_on_because_only_fills_are_recorded() {
346        let deduper = AccountEventDeduper::new();
347        let notice = || UnindexedAccountEvent {
348            exchange: ExchangeId::BinanceSpot,
349            kind: AccountEventKind::Notice(crate::notice::AccountNotice::new(
350                crate::notice::NoticeKind::Other,
351                None,
352                DateTime::UNIX_EPOCH,
353                None,
354                "STATUS".into(),
355            )),
356        };
357
358        // The client-level dedup would treat the second as a duplicate.
359        assert!(dedup_key_from_event(&notice()).is_some());
360        assert!(!deduper.is_duplicate(&notice()));
361        assert!(!deduper.is_duplicate(&notice()));
362    }
363
364    /// A copy of a debt update keys alike, but a second change to the same asset in the same
365    /// millisecond does not: it states a different debt, which must reach the consumer.
366    #[test]
367    fn only_a_copy_of_a_margin_debt_update_keys_alike() {
368        use crate::{
369            balance::MarginDetails,
370            margin_debt::{MarginDebtKind, MarginDebtUpdate},
371        };
372
373        let update = |kind, borrowed: i64, interest: i64| {
374            dedup_key_from_event(&UnindexedAccountEvent {
375                exchange: ExchangeId::BinanceMargin,
376                kind: AccountEventKind::MarginDebt(MarginDebtUpdate::new(
377                    AssetNameExchange::new("BTC"),
378                    None,
379                    MarginDetails::new(Decimal::new(borrowed, 8), Decimal::new(interest, 8)),
380                    kind,
381                    "VENUE".into(),
382                    DateTime::UNIX_EPOCH,
383                )),
384            })
385        };
386
387        assert!(update(MarginDebtKind::Repay, 6_000, 2).is_some());
388        assert_eq!(
389            update(MarginDebtKind::Repay, 6_000, 2),
390            update(MarginDebtKind::Repay, 6_000, 2)
391        );
392        assert_ne!(
393            update(MarginDebtKind::Repay, 6_000, 2),
394            update(MarginDebtKind::Repay, 3_000, 2)
395        );
396        assert_ne!(
397            update(MarginDebtKind::Repay, 6_000, 2),
398            update(MarginDebtKind::Repay, 6_000, 0)
399        );
400        assert_ne!(
401            update(MarginDebtKind::Repay, 6_000, 2),
402            update(MarginDebtKind::Interest, 6_000, 2)
403        );
404    }
405}