nautilus_indicators/ratio/
spread_analyzer.rs1use std::fmt::Display;
17
18use nautilus_core::correctness::FAILED;
19use nautilus_model::{data::QuoteTick, identifiers::InstrumentId};
20
21use crate::indicator::Indicator;
22
23#[repr(C)]
27#[derive(Debug)]
28#[cfg_attr(
29 feature = "python",
30 pyo3::pyclass(module = "nautilus_trader.indicators")
31)]
32#[cfg_attr(
33 feature = "python",
34 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.indicators")
35)]
36pub struct SpreadAnalyzer {
37 pub capacity: usize,
38 pub instrument_id: InstrumentId,
39 pub current: f64,
40 pub average: f64,
41 pub initialized: bool,
42 has_inputs: bool,
43 spreads: Vec<f64>,
44}
45
46impl Display for SpreadAnalyzer {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 write!(
49 f,
50 "{}({},{})",
51 self.name(),
52 self.capacity,
53 self.instrument_id
54 )
55 }
56}
57
58impl Indicator for SpreadAnalyzer {
59 fn name(&self) -> String {
60 stringify!(SpreadAnalyzer).to_string()
61 }
62
63 fn has_inputs(&self) -> bool {
64 self.has_inputs
65 }
66 fn initialized(&self) -> bool {
67 self.initialized
68 }
69
70 fn handle_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
71 if quote.instrument_id != self.instrument_id {
72 return Ok(());
73 }
74
75 if !self.initialized {
77 self.has_inputs = true;
78
79 if self.spreads.len() == self.capacity {
80 self.initialized = true;
81 }
82 }
83
84 let bid: f64 = quote.bid_price.into();
85 let ask: f64 = quote.ask_price.into();
86 let spread = ask - bid;
87
88 self.current = spread;
89 self.spreads.push(spread);
90
91 if self.spreads.len() > self.capacity {
94 self.spreads.remove(0);
95 }
96
97 self.average = fast_mean(&self.spreads);
103 Ok(())
104 }
105
106 fn reset(&mut self) {
107 self.current = 0.0;
108 self.average = 0.0;
109 self.spreads.clear();
110 self.initialized = false;
111 self.has_inputs = false;
112 }
113}
114
115impl SpreadAnalyzer {
116 #[must_use]
124 pub fn new(capacity: usize, instrument_id: InstrumentId) -> Self {
125 Self::new_checked(capacity, instrument_id).expect(FAILED)
126 }
127
128 pub(crate) fn new_checked(
129 capacity: usize,
130 instrument_id: InstrumentId,
131 ) -> anyhow::Result<Self> {
132 let mut spreads = Vec::new();
133 spreads.try_reserve_exact(capacity)?;
134 Ok(Self {
135 capacity,
136 instrument_id,
137 current: 0.0,
138 average: 0.0,
139 initialized: false,
140 has_inputs: false,
141 spreads,
142 })
143 }
144}
145
146fn fast_mean(values: &[f64]) -> f64 {
147 if values.is_empty() {
148 0.0
149 } else {
150 values.iter().sum::<f64>() / values.len() as f64
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use nautilus_model::identifiers::InstrumentId;
157 use rstest::rstest;
158
159 use crate::{
160 indicator::Indicator,
161 ratio::spread_analyzer::SpreadAnalyzer,
162 stubs::{spread_analyzer_10, *},
163 testing::assert_approx_equal,
164 };
165
166 #[rstest]
167 #[case(usize::MAX)]
168 #[case(isize::MAX as usize / size_of::<f64>() + 1)]
169 fn test_checked_constructor_rejects_capacity_overflow(#[case] capacity: usize) {
170 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
171 let error = SpreadAnalyzer::new_checked(capacity, instrument_id).unwrap_err();
172 assert_eq!(
173 error.to_string(),
174 "memory allocation failed because the computed capacity exceeded the collection's maximum"
175 );
176 }
177
178 #[rstest]
179 #[case(0)]
180 #[case(10)]
181 fn test_checked_constructor_preserves_capacity(#[case] capacity: usize) {
182 let instrument_id = InstrumentId::from("ETHUSDT-PERP.BINANCE");
183 let ind = SpreadAnalyzer::new_checked(capacity, instrument_id).unwrap();
184 assert_eq!(ind.capacity, capacity);
185 assert_eq!(ind.instrument_id, instrument_id);
186 assert_eq!(ind.current, 0.0);
187 assert_eq!(ind.average, 0.0);
188 assert!(!ind.initialized());
189 assert!(!ind.has_inputs());
190 }
191
192 #[rstest]
193 fn test_efficiency_ratio_initialized(spread_analyzer_10: SpreadAnalyzer) {
194 let display_str = format!("{spread_analyzer_10}");
195 assert_eq!(display_str, "SpreadAnalyzer(10,ETHUSDT-PERP.BINANCE)");
196 assert_eq!(spread_analyzer_10.capacity, 10);
197 assert!(!spread_analyzer_10.initialized);
198 }
199
200 #[rstest]
201 fn test_with_correct_number_of_required_inputs(mut spread_analyzer_10: SpreadAnalyzer) {
202 let bid_price: [&str; 10] = [
203 "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
204 "100.49", "100.51",
205 ];
206
207 let ask_price: [&str; 10] = [
208 "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
209 "100.54", "100.56",
210 ];
211
212 for i in 1..10 {
213 spread_analyzer_10
214 .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
215 .unwrap();
216 }
217 assert!(!spread_analyzer_10.initialized);
218 }
219
220 #[rstest]
221 fn test_value_with_one_input(mut spread_analyzer_10: SpreadAnalyzer) {
222 spread_analyzer_10
223 .handle_quote(&stub_quote("100.50", "100.55"))
224 .unwrap();
225 assert_approx_equal(spread_analyzer_10.average, 0.05);
226 }
227
228 #[rstest]
229 fn test_value_with_all_higher_inputs_returns_expected_value(
230 mut spread_analyzer_10: SpreadAnalyzer,
231 ) {
232 let bid_price: [&str; 15] = [
233 "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
234 "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
235 ];
236
237 let ask_price: [&str; 15] = [
238 "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
239 "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
240 ];
241
242 for i in 0..10 {
243 spread_analyzer_10
244 .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
245 .unwrap();
246 }
247
248 assert_approx_equal(spread_analyzer_10.average, 0.05);
249 }
250
251 #[rstest]
252 fn test_handles_more_inputs_than_capacity_without_panic(
253 mut spread_analyzer_10: SpreadAnalyzer,
254 ) {
255 let bid_price: [&str; 15] = [
259 "100.50", "100.45", "100.55", "100.60", "100.52", "100.48", "100.53", "100.57",
260 "100.49", "100.51", "100.54", "100.56", "100.58", "100.50", "100.52",
261 ];
262
263 let ask_price: [&str; 15] = [
264 "100.55", "100.50", "100.60", "100.65", "100.57", "100.53", "100.58", "100.62",
265 "100.54", "100.56", "100.59", "100.61", "100.63", "100.55", "100.57",
266 ];
267
268 for i in 0..15 {
269 spread_analyzer_10
270 .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
271 .unwrap();
272 }
273
274 assert!(spread_analyzer_10.initialized());
275 assert_eq!(spread_analyzer_10.spreads.len(), 10);
276 assert!((spread_analyzer_10.average - 0.05).abs() < 1e-9);
277 }
278
279 #[rstest]
280 fn test_average_tracks_varying_spreads_past_capacity(mut spread_analyzer_10: SpreadAnalyzer) {
281 let bid_price: [&str; 15] = ["100.00"; 15];
287 let ask_price: [&str; 15] = [
288 "100.01", "100.02", "100.03", "100.04", "100.05", "100.06", "100.07", "100.08",
289 "100.09", "100.10", "100.11", "100.12", "100.13", "100.14", "100.15",
290 ];
291
292 for i in 0..15 {
293 spread_analyzer_10
294 .handle_quote(&stub_quote(bid_price[i], ask_price[i]))
295 .unwrap();
296 }
297
298 assert_eq!(spread_analyzer_10.spreads.len(), 10);
299 assert!((spread_analyzer_10.average - 0.105).abs() < 1e-9);
300 }
301
302 #[rstest]
303 fn test_reset_successfully_returns_indicator_to_fresh_state(
304 mut spread_analyzer_10: SpreadAnalyzer,
305 ) {
306 spread_analyzer_10
307 .handle_quote(&stub_quote("100.50", "100.55"))
308 .unwrap();
309 spread_analyzer_10.reset();
310 assert!(!spread_analyzer_10.initialized());
311 assert_eq!(spread_analyzer_10.current, 0.0);
312 assert_eq!(spread_analyzer_10.average, 0.0);
313 assert!(!spread_analyzer_10.has_inputs);
314 assert!(!spread_analyzer_10.initialized);
315 }
316}