1use ahash::{AHashMap, AHashSet};
19use nautilus_common::messages::DataEvent;
20use nautilus_model::{
21 identifiers::InstrumentId,
22 instruments::{Instrument, InstrumentAny},
23};
24
25fn economics_differ(a: &InstrumentAny, b: &InstrumentAny) -> bool {
31 a.maker_fee() != b.maker_fee()
32 || a.taker_fee() != b.taker_fee()
33 || a.margin_init() != b.margin_init()
34 || a.margin_maint() != b.margin_maint()
35 || a.price_precision() != b.price_precision()
36 || a.size_precision() != b.size_precision()
37 || a.price_increment() != b.price_increment()
38 || a.size_increment() != b.size_increment()
39 || a.multiplier() != b.multiplier()
40 || a.lot_size() != b.lot_size()
41 || a.min_quantity() != b.min_quantity()
42 || a.max_quantity() != b.max_quantity()
43 || a.min_notional() != b.min_notional()
44 || a.max_notional() != b.max_notional()
45 || a.min_price() != b.min_price()
46 || a.max_price() != b.max_price()
47}
48
49pub fn diff_and_emit_instruments(
56 new_instruments: &[InstrumentAny],
57 cached: &mut AHashMap<InstrumentId, InstrumentAny>,
58 subscriptions: Option<&AHashSet<InstrumentId>>,
59 sender: &tokio::sync::mpsc::UnboundedSender<DataEvent>,
60) {
61 let is_subscribed = |id: &InstrumentId| subscriptions.is_none_or(|subs| subs.contains(id));
62
63 for instrument in new_instruments {
64 let id = instrument.id();
65 let changed = cached
66 .get(&id)
67 .is_none_or(|prev| economics_differ(prev, instrument));
68
69 if changed {
70 cached.insert(id, instrument.clone());
71 if is_subscribed(&id)
72 && let Err(e) = sender.send(DataEvent::Instrument(instrument.clone()))
73 {
74 log::error!("Failed to emit instrument event: {e}");
75 }
76 }
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use nautilus_core::UnixNanos;
83 use nautilus_model::{
84 identifiers::{InstrumentId, Symbol},
85 instruments::{CryptoPerpetual, InstrumentAny},
86 types::{Currency, Money, Price, Quantity},
87 };
88 use rstest::rstest;
89 use rust_decimal::Decimal;
90 use rust_decimal_macros::dec;
91
92 use super::*;
93
94 fn perp(
97 maker_fee: Decimal,
98 taker_fee: Decimal,
99 size_increment: Quantity,
100 min_notional: Option<Money>,
101 ) -> InstrumentAny {
102 InstrumentAny::CryptoPerpetual(
103 CryptoPerpetual::builder()
104 .instrument_id(InstrumentId::from("BTCUSDT-LINEAR.BYBIT"))
105 .raw_symbol(Symbol::from("BTCUSDT-LINEAR"))
106 .base_currency(Currency::BTC())
107 .quote_currency(Currency::USDT())
108 .settlement_currency(Currency::USDT())
109 .is_inverse(false)
110 .price_precision(1)
111 .size_precision(3)
112 .price_increment(Price::from("0.1"))
113 .size_increment(size_increment)
114 .min_quantity(Quantity::from("0.001"))
115 .maybe_min_notional(min_notional)
116 .maker_fee(maker_fee)
117 .taker_fee(taker_fee)
118 .ts_event(UnixNanos::default())
119 .ts_init(UnixNanos::default())
120 .build()
121 .unwrap(),
122 )
123 }
124
125 fn default_perp() -> InstrumentAny {
126 perp(dec!(0.0001), dec!(0.00055), Quantity::from("0.001"), None)
127 }
128
129 #[rstest]
130 fn test_emits_for_new_instrument() {
131 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
132 let instrument = default_perp();
133 let mut cached = AHashMap::new();
134
135 diff_and_emit_instruments(std::slice::from_ref(&instrument), &mut cached, None, &tx);
136
137 match rx.try_recv().expect("expected instrument event") {
138 DataEvent::Instrument(emitted) => assert_eq!(emitted.id(), instrument.id()),
139 _ => panic!("expected Instrument event"),
140 }
141 assert!(cached.contains_key(&instrument.id()));
142 }
143
144 #[rstest]
145 fn test_no_emit_when_unchanged() {
146 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
147 let instrument = default_perp();
148 let mut cached = AHashMap::new();
149 cached.insert(instrument.id(), default_perp());
150
151 diff_and_emit_instruments(&[instrument], &mut cached, None, &tx);
152
153 assert!(rx.try_recv().is_err());
154 }
155
156 #[rstest]
157 fn test_emits_on_fee_change() {
158 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
159 let id = default_perp().id();
160 let mut cached = AHashMap::new();
161 cached.insert(id, default_perp());
162
163 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
165 diff_and_emit_instruments(&[updated], &mut cached, None, &tx);
166
167 match rx
168 .try_recv()
169 .expect("expected instrument event on fee change")
170 {
171 DataEvent::Instrument(emitted) => assert_eq!(emitted.taker_fee(), dec!(0.0008)),
172 _ => panic!("expected Instrument event"),
173 }
174 assert_eq!(cached.get(&id).unwrap().taker_fee(), dec!(0.0008));
175 }
176
177 #[rstest]
178 fn test_emits_on_size_increment_and_min_notional_change() {
179 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
180 let id = default_perp().id();
181
182 let mut cached = AHashMap::new();
184 cached.insert(id, default_perp());
185 let bigger_step = perp(dec!(0.0001), dec!(0.00055), Quantity::from("0.002"), None);
186 diff_and_emit_instruments(&[bigger_step], &mut cached, None, &tx);
187 assert!(rx.try_recv().is_ok(), "size_increment change should emit");
188
189 let mut cached = AHashMap::new();
191 cached.insert(id, default_perp());
192 let with_min = perp(
193 dec!(0.0001),
194 dec!(0.00055),
195 Quantity::from("0.001"),
196 Some(Money::new(5.0, Currency::USDT())),
197 );
198 diff_and_emit_instruments(&[with_min], &mut cached, None, &tx);
199 assert!(rx.try_recv().is_ok(), "min_notional change should emit");
200 }
201
202 #[rstest]
203 fn test_subscription_gating_updates_cache_but_only_emits_for_subscribed() {
204 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
205 let id = default_perp().id();
206
207 let empty_subs = AHashSet::new();
209 let mut cached = AHashMap::new();
210 cached.insert(id, default_perp());
211 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
212
213 diff_and_emit_instruments(&[updated], &mut cached, Some(&empty_subs), &tx);
214
215 assert!(rx.try_recv().is_err(), "unsubscribed should not emit");
216 assert_eq!(
217 cached.get(&id).unwrap().taker_fee(),
218 dec!(0.0008),
219 "cache should update regardless of subscription"
220 );
221
222 let mut subs = AHashSet::new();
224 subs.insert(id);
225 let mut cached = AHashMap::new();
226 cached.insert(id, default_perp());
227 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
228
229 diff_and_emit_instruments(&[updated], &mut cached, Some(&subs), &tx);
230
231 assert!(rx.try_recv().is_ok(), "subscribed should emit");
232 }
233}