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(¬ice()).is_some());
360 assert!(!deduper.is_duplicate(¬ice()));
361 assert!(!deduper.is_duplicate(¬ice()));
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}