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