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