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(CryptoPerpetual::new(
103 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
104 Symbol::from("BTCUSDT-LINEAR"),
105 Currency::BTC(),
106 Currency::USDT(),
107 Currency::USDT(),
108 false, 1, 3, Price::from("0.1"),
112 size_increment,
113 None, None, None, Some(Quantity::from("0.001")), None, min_notional,
119 None, None, None, None, Some(maker_fee),
124 Some(taker_fee),
125 None, None, UnixNanos::default(), UnixNanos::default(), ))
130 }
131
132 fn default_perp() -> InstrumentAny {
133 perp(dec!(0.0001), dec!(0.00055), Quantity::from("0.001"), None)
134 }
135
136 #[rstest]
137 fn test_emits_for_new_instrument() {
138 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
139 let instrument = default_perp();
140 let mut cached = AHashMap::new();
141
142 diff_and_emit_instruments(std::slice::from_ref(&instrument), &mut cached, None, &tx);
143
144 match rx.try_recv().expect("expected instrument event") {
145 DataEvent::Instrument(emitted) => assert_eq!(emitted.id(), instrument.id()),
146 _ => panic!("expected Instrument event"),
147 }
148 assert!(cached.contains_key(&instrument.id()));
149 }
150
151 #[rstest]
152 fn test_no_emit_when_unchanged() {
153 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
154 let instrument = default_perp();
155 let mut cached = AHashMap::new();
156 cached.insert(instrument.id(), default_perp());
157
158 diff_and_emit_instruments(&[instrument], &mut cached, None, &tx);
159
160 assert!(rx.try_recv().is_err());
161 }
162
163 #[rstest]
164 fn test_emits_on_fee_change() {
165 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
166 let id = default_perp().id();
167 let mut cached = AHashMap::new();
168 cached.insert(id, default_perp());
169
170 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
172 diff_and_emit_instruments(&[updated], &mut cached, None, &tx);
173
174 match rx
175 .try_recv()
176 .expect("expected instrument event on fee change")
177 {
178 DataEvent::Instrument(emitted) => assert_eq!(emitted.taker_fee(), dec!(0.0008)),
179 _ => panic!("expected Instrument event"),
180 }
181 assert_eq!(cached.get(&id).unwrap().taker_fee(), dec!(0.0008));
182 }
183
184 #[rstest]
185 fn test_emits_on_size_increment_and_min_notional_change() {
186 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
187 let id = default_perp().id();
188
189 let mut cached = AHashMap::new();
191 cached.insert(id, default_perp());
192 let bigger_step = perp(dec!(0.0001), dec!(0.00055), Quantity::from("0.002"), None);
193 diff_and_emit_instruments(&[bigger_step], &mut cached, None, &tx);
194 assert!(rx.try_recv().is_ok(), "size_increment change should emit");
195
196 let mut cached = AHashMap::new();
198 cached.insert(id, default_perp());
199 let with_min = perp(
200 dec!(0.0001),
201 dec!(0.00055),
202 Quantity::from("0.001"),
203 Some(Money::new(5.0, Currency::USDT())),
204 );
205 diff_and_emit_instruments(&[with_min], &mut cached, None, &tx);
206 assert!(rx.try_recv().is_ok(), "min_notional change should emit");
207 }
208
209 #[rstest]
210 fn test_subscription_gating_updates_cache_but_only_emits_for_subscribed() {
211 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
212 let id = default_perp().id();
213
214 let empty_subs = AHashSet::new();
216 let mut cached = AHashMap::new();
217 cached.insert(id, default_perp());
218 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
219
220 diff_and_emit_instruments(&[updated], &mut cached, Some(&empty_subs), &tx);
221
222 assert!(rx.try_recv().is_err(), "unsubscribed should not emit");
223 assert_eq!(
224 cached.get(&id).unwrap().taker_fee(),
225 dec!(0.0008),
226 "cache should update regardless of subscription"
227 );
228
229 let mut subs = AHashSet::new();
231 subs.insert(id);
232 let mut cached = AHashMap::new();
233 cached.insert(id, default_perp());
234 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
235
236 diff_and_emit_instruments(&[updated], &mut cached, Some(&subs), &tx);
237
238 assert!(rx.try_recv().is_ok(), "subscribed should emit");
239 }
240}