1use ahash::{AHashMap, AHashSet};
19use nautilus_common::{live::sender::EventSender, 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: &EventSender<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(
136 std::slice::from_ref(&instrument),
137 &mut cached,
138 None,
139 &tx.into(),
140 );
141
142 match rx.try_recv().expect("expected instrument event") {
143 DataEvent::Instrument(emitted) => assert_eq!(emitted.id(), instrument.id()),
144 _ => panic!("expected Instrument event"),
145 }
146 assert!(cached.contains_key(&instrument.id()));
147 }
148
149 #[rstest]
150 fn test_no_emit_when_unchanged() {
151 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
152 let instrument = default_perp();
153 let mut cached = AHashMap::new();
154 cached.insert(instrument.id(), default_perp());
155
156 diff_and_emit_instruments(&[instrument], &mut cached, None, &tx.into());
157
158 assert!(rx.try_recv().is_err());
159 }
160
161 #[rstest]
162 fn test_emits_on_fee_change() {
163 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
164 let id = default_perp().id();
165 let mut cached = AHashMap::new();
166 cached.insert(id, default_perp());
167
168 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
170 diff_and_emit_instruments(&[updated], &mut cached, None, &tx.into());
171
172 match rx
173 .try_recv()
174 .expect("expected instrument event on fee change")
175 {
176 DataEvent::Instrument(emitted) => assert_eq!(emitted.taker_fee(), dec!(0.0008)),
177 _ => panic!("expected Instrument event"),
178 }
179 assert_eq!(cached.get(&id).unwrap().taker_fee(), dec!(0.0008));
180 }
181
182 #[rstest]
183 fn test_emits_on_size_increment_and_min_notional_change() {
184 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
185 let id = default_perp().id();
186
187 let mut cached = AHashMap::new();
189 cached.insert(id, default_perp());
190 let bigger_step = perp(dec!(0.0001), dec!(0.00055), Quantity::from("0.002"), None);
191 diff_and_emit_instruments(&[bigger_step], &mut cached, None, &tx.clone().into());
192 assert!(rx.try_recv().is_ok(), "size_increment change should emit");
193
194 let mut cached = AHashMap::new();
196 cached.insert(id, default_perp());
197 let with_min = perp(
198 dec!(0.0001),
199 dec!(0.00055),
200 Quantity::from("0.001"),
201 Some(Money::new(5.0, Currency::USDT())),
202 );
203 diff_and_emit_instruments(&[with_min], &mut cached, None, &tx.into());
204 assert!(rx.try_recv().is_ok(), "min_notional change should emit");
205 }
206
207 #[rstest]
208 fn test_subscription_gating_updates_cache_but_only_emits_for_subscribed() {
209 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
210 let id = default_perp().id();
211
212 let empty_subs = AHashSet::new();
214 let mut cached = AHashMap::new();
215 cached.insert(id, default_perp());
216 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
217
218 diff_and_emit_instruments(
219 &[updated],
220 &mut cached,
221 Some(&empty_subs),
222 &tx.clone().into(),
223 );
224
225 assert!(rx.try_recv().is_err(), "unsubscribed should not emit");
226 assert_eq!(
227 cached.get(&id).unwrap().taker_fee(),
228 dec!(0.0008),
229 "cache should update regardless of subscription"
230 );
231
232 let mut subs = AHashSet::new();
234 subs.insert(id);
235 let mut cached = AHashMap::new();
236 cached.insert(id, default_perp());
237 let updated = perp(dec!(0.0001), dec!(0.0008), Quantity::from("0.001"), None);
238
239 diff_and_emit_instruments(&[updated], &mut cached, Some(&subs), &tx.into());
240
241 assert!(rx.try_recv().is_ok(), "subscribed should emit");
242 }
243}