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(¬ice()).is_some());
325 assert!(!deduper.is_duplicate(¬ice()));
326 assert!(!deduper.is_duplicate(¬ice()));
327 }
328}