use crate::{
AccountEventKind, UnindexedAccountEvent, margin_debt::MarginDebtKind, order::state::OrderState,
};
use lru::LruCache;
use rust_decimal::Decimal;
use smol_str::SmolStr;
use std::{num::NonZeroUsize, sync::Arc};
pub(crate) const DEDUP_CACHE_SIZE: usize = 10_000;
#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
pub(crate) enum DedupEventKind {
Trade,
OrderState {
filled_quantity: Decimal,
},
Cancelled,
#[cfg(feature = "hyperliquid")]
Funding,
Notice {
time_exchange_ms: i64,
},
MarginDebt {
time_exchange_ms: i64,
kind: MarginDebtKind,
borrowed: Decimal,
interest: Decimal,
},
}
#[derive(Debug, Hash, Eq, PartialEq)]
pub(crate) struct DedupKey {
pub(crate) instrument: SmolStr,
pub(crate) id: SmolStr,
pub(crate) kind: DedupEventKind,
}
pub(crate) type SharedDedupCache = Arc<parking_lot::Mutex<LruCache<DedupKey, ()>>>;
pub(crate) fn new_dedup_cache() -> SharedDedupCache {
#[allow(clippy::unwrap_used)]
Arc::new(parking_lot::Mutex::new(LruCache::new(
NonZeroUsize::new(DEDUP_CACHE_SIZE).unwrap(),
)))
}
pub(crate) fn dedup_key_from_event(event: &UnindexedAccountEvent) -> Option<DedupKey> {
match &event.kind {
AccountEventKind::Trade(trade) => Some(DedupKey {
instrument: trade.instrument.name().clone(),
id: trade.id.0.clone(),
kind: DedupEventKind::Trade,
}),
AccountEventKind::OrderSnapshot(snap) => {
match &snap.0.state {
OrderState::Active(active) => {
use crate::order::state::ActiveOrderState;
match active {
ActiveOrderState::Open(open) => Some(DedupKey {
instrument: snap.0.key.instrument.name().clone(),
id: match open.id.assigned() {
Some(id) => id.0.clone(),
None => snap.0.key.cid.0.clone(),
},
kind: DedupEventKind::OrderState {
filled_quantity: open.filled_quantity,
},
}),
_ => None,
}
}
_ => None,
}
}
AccountEventKind::OrderCancelled(resp) => match &resp.state {
Ok(cancelled) => Some(DedupKey {
instrument: resp.key.instrument.name().clone(),
id: cancelled.id.0.clone(),
kind: DedupEventKind::Cancelled,
}),
Err(_) => None, },
AccountEventKind::Notice(notice) => Some(DedupKey {
instrument: notice
.instrument
.as_ref()
.map(|instrument| instrument.name().clone())
.unwrap_or_default(),
id: notice.status.clone(),
kind: DedupEventKind::Notice {
time_exchange_ms: notice.time_exchange.timestamp_millis(),
},
}),
AccountEventKind::MarginDebt(update) => Some(DedupKey {
instrument: update
.instrument
.as_ref()
.map(|instrument| instrument.name().clone())
.unwrap_or_default(),
id: update.asset.name().clone(),
kind: DedupEventKind::MarginDebt {
time_exchange_ms: update.time_exchange.timestamp_millis(),
kind: update.kind,
borrowed: update.debt.borrowed,
interest: update.debt.interest,
},
}),
_ => None, }
}
pub(crate) fn is_duplicate(cache: &SharedDedupCache, key: DedupKey) -> bool {
let mut guard = cache.lock();
if guard.peek(&key).is_some() {
return true;
}
guard.put(key, ());
false
}
#[derive(Debug, Clone)]
pub struct AccountEventDeduper(SharedDedupCache);
impl Default for AccountEventDeduper {
fn default() -> Self {
Self::new()
}
}
impl AccountEventDeduper {
pub fn new() -> Self {
Self(new_dedup_cache())
}
pub fn is_duplicate(&self, event: &UnindexedAccountEvent) -> bool {
matches!(event.kind, AccountEventKind::Trade(_))
&& dedup_key_from_event(event).is_some_and(|key| is_duplicate(&self.0, key))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
order::id::{OrderId, StrategyId},
trade::{AssetFees, Trade, TradeId},
};
use chrono::DateTime;
use rust_decimal::Decimal;
use rustrade_instrument::{
Side, asset::name::AssetNameExchange, exchange::ExchangeId,
instrument::name::InstrumentNameExchange,
};
fn trade_event(instrument: &str, id: &str) -> UnindexedAccountEvent {
UnindexedAccountEvent {
exchange: ExchangeId::BinanceSpot,
kind: AccountEventKind::Trade(Trade::new(
TradeId::new(id),
OrderId::new("o"),
InstrumentNameExchange::new(instrument),
StrategyId::unknown(),
DateTime::UNIX_EPOCH,
Side::Buy,
Decimal::ONE,
Decimal::ONE,
None,
AssetFees::new(AssetNameExchange::from("usdt"), Decimal::ZERO, None),
)),
}
}
#[test]
fn a_trade_is_a_duplicate_the_second_time_it_is_seen_by_instrument_and_id() {
let deduper = AccountEventDeduper::new();
assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
assert!(deduper.is_duplicate(&trade_event("btc_usdt", "1")));
assert!(!deduper.is_duplicate(&trade_event("eth_usdt", "1")));
assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "2")));
}
#[test]
fn clones_share_one_record() {
let deduper = AccountEventDeduper::new();
let clone = deduper.clone();
assert!(!deduper.is_duplicate(&trade_event("btc_usdt", "1")));
assert!(clone.is_duplicate(&trade_event("btc_usdt", "1")));
}
#[test]
fn an_event_that_is_not_a_fill_is_never_a_duplicate() {
let deduper = AccountEventDeduper::new();
let event = UnindexedAccountEvent {
exchange: ExchangeId::BinanceSpot,
kind: AccountEventKind::StreamTerminated(
crate::error::StreamTerminationReason::RateLimited {
retry_after: std::time::Duration::from_secs(1),
},
),
};
assert!(!deduper.is_duplicate(&event));
assert!(!deduper.is_duplicate(&event));
}
#[test]
fn a_repeated_notice_is_passed_on_because_only_fills_are_recorded() {
let deduper = AccountEventDeduper::new();
let notice = || UnindexedAccountEvent {
exchange: ExchangeId::BinanceSpot,
kind: AccountEventKind::Notice(crate::notice::AccountNotice::new(
crate::notice::NoticeKind::Other,
None,
DateTime::UNIX_EPOCH,
None,
"STATUS".into(),
)),
};
assert!(dedup_key_from_event(¬ice()).is_some());
assert!(!deduper.is_duplicate(¬ice()));
assert!(!deduper.is_duplicate(¬ice()));
}
#[test]
fn only_a_copy_of_a_margin_debt_update_keys_alike() {
use crate::{
balance::MarginDetails,
margin_debt::{MarginDebtKind, MarginDebtUpdate},
};
let update = |kind, borrowed: i64, interest: i64| {
dedup_key_from_event(&UnindexedAccountEvent {
exchange: ExchangeId::BinanceMargin,
kind: AccountEventKind::MarginDebt(MarginDebtUpdate::new(
AssetNameExchange::new("BTC"),
None,
MarginDetails::new(Decimal::new(borrowed, 8), Decimal::new(interest, 8)),
kind,
"VENUE".into(),
DateTime::UNIX_EPOCH,
)),
})
};
assert!(update(MarginDebtKind::Repay, 6_000, 2).is_some());
assert_eq!(
update(MarginDebtKind::Repay, 6_000, 2),
update(MarginDebtKind::Repay, 6_000, 2)
);
assert_ne!(
update(MarginDebtKind::Repay, 6_000, 2),
update(MarginDebtKind::Repay, 3_000, 2)
);
assert_ne!(
update(MarginDebtKind::Repay, 6_000, 2),
update(MarginDebtKind::Repay, 6_000, 0)
);
assert_ne!(
update(MarginDebtKind::Repay, 6_000, 2),
update(MarginDebtKind::Interest, 6_000, 2)
);
}
}