rusty_backtest/features/
asymmetry.rs

1//! Asymmetry Index for Order Book Skewness Analysis
2//!
3//! Implements various asymmetry metrics to detect directional pressure
4//! and potential price movements based on order book imbalances.
5
6use super::{OrderBookSnapshot, decimal_to_f64_or_nan};
7
8/// Asymmetry Index calculator for order book skewness
9///
10/// Measures the asymmetry between bid and ask sides of the order book
11/// to identify potential directional pressure and price movements.
12#[derive(Debug, Clone)]
13pub struct AsymmetryIndexCalculator {
14    /// Number of price levels to consider
15    max_levels: usize,
16    /// Use volume-weighted calculation
17    #[allow(dead_code)]
18    volume_weighted: bool,
19    /// Price distance weighting decay factor (0-1)
20    distance_decay: f64,
21    /// Rolling window for smoothing
22    window_size: usize,
23    /// Historical asymmetry values
24    history: Vec<f64>,
25    /// Current position in circular buffer
26    buffer_position: usize,
27    /// Number of samples collected
28    samples_count: usize,
29}
30
31impl AsymmetryIndexCalculator {
32    /// Create a new asymmetry index calculator
33    ///
34    /// # Arguments
35    /// * `max_levels` - Maximum number of price levels to analyze
36    /// * `volume_weighted` - Whether to weight by volume
37    /// * `distance_decay` - Decay factor for price distance weighting (0-1)
38    /// * `window_size` - Size of rolling window for smoothing
39    #[must_use]
40    pub fn new(
41        max_levels: usize,
42        volume_weighted: bool,
43        distance_decay: f64,
44        window_size: usize,
45    ) -> Self {
46        Self {
47            max_levels,
48            volume_weighted,
49            distance_decay: distance_decay.clamp(0.0, 1.0),
50            window_size,
51            history: vec![0.0; window_size],
52            buffer_position: 0,
53            samples_count: 0,
54        }
55    }
56
57    /// Calculate asymmetry index from order book snapshot
58    pub fn calculate(&mut self, snapshot: &OrderBookSnapshot) -> AsymmetryMetrics {
59        // Calculate basic asymmetry
60        let basic_asymmetry = self.calculate_basic_asymmetry(snapshot);
61
62        // Calculate weighted asymmetry
63        let weighted_asymmetry = self.calculate_weighted_asymmetry(snapshot);
64
65        // Calculate volume-weighted asymmetry
66        let volume_asymmetry = self.calculate_volume_asymmetry(snapshot);
67
68        // Calculate order count asymmetry
69        let order_count_asymmetry = self.calculate_order_count_asymmetry(snapshot);
70
71        // Store in history for smoothing
72        self.history[self.buffer_position] = weighted_asymmetry;
73        self.buffer_position = (self.buffer_position + 1) % self.window_size;
74        self.samples_count = self.samples_count.saturating_add(1).min(self.window_size);
75
76        // Calculate smoothed asymmetry
77        let smoothed_asymmetry = if self.samples_count > 0 {
78            self.history.iter().take(self.samples_count).sum::<f64>() / self.samples_count as f64
79        } else {
80            weighted_asymmetry
81        };
82
83        AsymmetryMetrics {
84            basic_asymmetry,
85            weighted_asymmetry,
86            volume_asymmetry,
87            order_count_asymmetry,
88            smoothed_asymmetry,
89            directional_pressure: self.calculate_directional_pressure(smoothed_asymmetry),
90        }
91    }
92
93    /// Calculate basic price-level asymmetry
94    fn calculate_basic_asymmetry(&self, snapshot: &OrderBookSnapshot) -> f64 {
95        let levels = self
96            .max_levels
97            .min(snapshot.bids.len())
98            .min(snapshot.asks.len());
99        if levels == 0 {
100            return 0.0;
101        }
102
103        let mid_price = decimal_to_f64_or_nan(snapshot.mid_price());
104        if mid_price <= 0.0 {
105            return 0.0;
106        }
107
108        let mut bid_score = 0.0;
109        let mut ask_score = 0.0;
110
111        for i in 0..levels {
112            let bid_price = decimal_to_f64_or_nan(snapshot.bids[i].price);
113            let ask_price = decimal_to_f64_or_nan(snapshot.asks[i].price);
114
115            // Calculate price distances from mid
116            let bid_distance = (mid_price - bid_price).abs() / mid_price;
117            let ask_distance = (ask_price - mid_price).abs() / mid_price;
118
119            // Equal weight for each level
120            bid_score += 1.0 / (1.0 + bid_distance);
121            ask_score += 1.0 / (1.0 + ask_distance);
122        }
123
124        // Normalize to [-1, 1]
125        (bid_score - ask_score) / (bid_score + ask_score)
126    }
127
128    /// Calculate weighted asymmetry with distance decay
129    fn calculate_weighted_asymmetry(&self, snapshot: &OrderBookSnapshot) -> f64 {
130        let levels = self
131            .max_levels
132            .min(snapshot.bids.len())
133            .min(snapshot.asks.len());
134        if levels == 0 {
135            return 0.0;
136        }
137
138        let mid_price = decimal_to_f64_or_nan(snapshot.mid_price());
139        if mid_price <= 0.0 {
140            return 0.0;
141        }
142
143        let mut bid_weighted_volume = 0.0;
144        let mut ask_weighted_volume = 0.0;
145
146        for i in 0..levels {
147            let bid = &snapshot.bids[i];
148            let ask = &snapshot.asks[i];
149
150            let bid_price = decimal_to_f64_or_nan(bid.price);
151            let ask_price = decimal_to_f64_or_nan(ask.price);
152
153            // Calculate price distances
154            let bid_distance = ((mid_price - bid_price) / mid_price).abs();
155            let ask_distance = ((ask_price - mid_price) / mid_price).abs();
156
157            // Apply distance decay
158            let bid_weight = self.distance_decay.powf(bid_distance * 100.0);
159            let ask_weight = self.distance_decay.powf(ask_distance * 100.0);
160
161            bid_weighted_volume += decimal_to_f64_or_nan(bid.quantity) * bid_weight;
162            ask_weighted_volume += decimal_to_f64_or_nan(ask.quantity) * ask_weight;
163        }
164
165        if bid_weighted_volume + ask_weighted_volume == 0.0 {
166            return 0.0;
167        }
168
169        // Normalize to [-1, 1]
170        (bid_weighted_volume - ask_weighted_volume) / (bid_weighted_volume + ask_weighted_volume)
171    }
172
173    /// Calculate pure volume asymmetry
174    fn calculate_volume_asymmetry(&self, snapshot: &OrderBookSnapshot) -> f64 {
175        let levels = self
176            .max_levels
177            .min(snapshot.bids.len())
178            .min(snapshot.asks.len());
179        if levels == 0 {
180            return 0.0;
181        }
182
183        let mut total_bid_volume = 0.0;
184        let mut total_ask_volume = 0.0;
185
186        for i in 0..levels {
187            total_bid_volume += decimal_to_f64_or_nan(snapshot.bids[i].quantity);
188            total_ask_volume += decimal_to_f64_or_nan(snapshot.asks[i].quantity);
189        }
190
191        if total_bid_volume + total_ask_volume == 0.0 {
192            return 0.0;
193        }
194
195        (total_bid_volume - total_ask_volume) / (total_bid_volume + total_ask_volume)
196    }
197
198    /// Calculate order count asymmetry
199    fn calculate_order_count_asymmetry(&self, snapshot: &OrderBookSnapshot) -> f64 {
200        let levels = self
201            .max_levels
202            .min(snapshot.bids.len())
203            .min(snapshot.asks.len());
204        if levels == 0 {
205            return 0.0;
206        }
207
208        let mut total_bid_orders = 0u32;
209        let mut total_ask_orders = 0u32;
210
211        for i in 0..levels {
212            total_bid_orders += snapshot.bids[i].order_count;
213            total_ask_orders += snapshot.asks[i].order_count;
214        }
215
216        if total_bid_orders + total_ask_orders == 0 {
217            return 0.0;
218        }
219
220        let bid_orders = total_bid_orders as f64;
221        let ask_orders = total_ask_orders as f64;
222
223        (bid_orders - ask_orders) / (bid_orders + ask_orders)
224    }
225
226    /// Calculate directional pressure indicator
227    fn calculate_directional_pressure(&self, smoothed_asymmetry: f64) -> DirectionalPressure {
228        if smoothed_asymmetry > 0.3 {
229            DirectionalPressure::StrongBullish
230        } else if smoothed_asymmetry > 0.1 {
231            DirectionalPressure::WeakBullish
232        } else if smoothed_asymmetry < -0.3 {
233            DirectionalPressure::StrongBearish
234        } else if smoothed_asymmetry < -0.1 {
235            DirectionalPressure::WeakBearish
236        } else {
237            DirectionalPressure::Neutral
238        }
239    }
240
241    /// Reset the calculator
242    pub fn reset(&mut self) {
243        self.history.fill(0.0);
244        self.buffer_position = 0;
245        self.samples_count = 0;
246    }
247}
248
249/// Asymmetry metrics output
250#[derive(Debug, Clone, Copy)]
251pub struct AsymmetryMetrics {
252    /// Basic price-level asymmetry [-1, 1]
253    pub basic_asymmetry: f64,
254    /// Distance-weighted asymmetry [-1, 1]
255    pub weighted_asymmetry: f64,
256    /// Pure volume asymmetry [-1, 1]
257    pub volume_asymmetry: f64,
258    /// Order count asymmetry [-1, 1]
259    pub order_count_asymmetry: f64,
260    /// Smoothed asymmetry over window [-1, 1]
261    pub smoothed_asymmetry: f64,
262    /// Directional pressure indicator
263    pub directional_pressure: DirectionalPressure,
264}
265
266/// Directional pressure levels
267#[derive(Debug, Clone, Copy, PartialEq)]
268pub enum DirectionalPressure {
269    /// Strong buying pressure detected (asymmetry > 0.3)
270    StrongBullish,
271    /// Weak buying pressure detected (asymmetry > 0.1)
272    WeakBullish,
273    /// Balanced market with no clear direction
274    Neutral,
275    /// Weak selling pressure detected (asymmetry < -0.1)
276    WeakBearish,
277    /// Strong selling pressure detected (asymmetry < -0.3)
278    StrongBearish,
279}
280
281/// Multi-timeframe asymmetry analyzer
282#[derive(Debug, Clone)]
283pub struct MultiTimeframeAsymmetry {
284    /// Short-term calculator (e.g., 10 samples)
285    short_term: AsymmetryIndexCalculator,
286    /// Medium-term calculator (e.g., 50 samples)
287    medium_term: AsymmetryIndexCalculator,
288    /// Long-term calculator (e.g., 200 samples)
289    long_term: AsymmetryIndexCalculator,
290}
291
292impl MultiTimeframeAsymmetry {
293    /// Create a new multi-timeframe analyzer
294    #[must_use]
295    pub fn new(max_levels: usize, distance_decay: f64) -> Self {
296        Self {
297            short_term: AsymmetryIndexCalculator::new(max_levels, true, distance_decay, 10),
298            medium_term: AsymmetryIndexCalculator::new(max_levels, true, distance_decay, 50),
299            long_term: AsymmetryIndexCalculator::new(max_levels, true, distance_decay, 200),
300        }
301    }
302
303    /// Update all timeframes and get composite signal
304    pub fn update(&mut self, snapshot: &OrderBookSnapshot) -> MultiTimeframeMetrics {
305        let short_metrics = self.short_term.calculate(snapshot);
306        let medium_metrics = self.medium_term.calculate(snapshot);
307        let long_metrics = self.long_term.calculate(snapshot);
308
309        // Calculate divergence between timeframes
310        let short_medium_divergence =
311            (short_metrics.smoothed_asymmetry - medium_metrics.smoothed_asymmetry).abs();
312        let medium_long_divergence =
313            (medium_metrics.smoothed_asymmetry - long_metrics.smoothed_asymmetry).abs();
314
315        // Determine trend alignment
316        let trend_aligned = short_metrics.smoothed_asymmetry.signum()
317            == medium_metrics.smoothed_asymmetry.signum()
318            && medium_metrics.smoothed_asymmetry.signum()
319                == long_metrics.smoothed_asymmetry.signum();
320
321        MultiTimeframeMetrics {
322            short_term: short_metrics,
323            medium_term: medium_metrics,
324            long_term: long_metrics,
325            short_medium_divergence,
326            medium_long_divergence,
327            trend_aligned,
328            composite_signal: self.calculate_composite_signal(
329                &short_metrics,
330                &medium_metrics,
331                &long_metrics,
332            ),
333        }
334    }
335
336    /// Calculate composite signal from all timeframes
337    fn calculate_composite_signal(
338        &self,
339        short: &AsymmetryMetrics,
340        medium: &AsymmetryMetrics,
341        long: &AsymmetryMetrics,
342    ) -> f64 {
343        // Weight: 50% short, 30% medium, 20% long
344        0.5 * short.smoothed_asymmetry
345            + 0.3 * medium.smoothed_asymmetry
346            + 0.2 * long.smoothed_asymmetry
347    }
348}
349
350/// Multi-timeframe metrics
351#[derive(Debug, Clone)]
352pub struct MultiTimeframeMetrics {
353    /// Short-term asymmetry metrics (10 samples)
354    pub short_term: AsymmetryMetrics,
355    /// Medium-term asymmetry metrics (50 samples)
356    pub medium_term: AsymmetryMetrics,
357    /// Long-term asymmetry metrics (200 samples)
358    pub long_term: AsymmetryMetrics,
359    /// Absolute divergence between short and medium term signals
360    pub short_medium_divergence: f64,
361    /// Absolute divergence between medium and long term signals
362    pub medium_long_divergence: f64,
363    /// Whether all timeframes show the same directional bias
364    pub trend_aligned: bool,
365    /// Weighted composite signal from all timeframes
366    pub composite_signal: f64,
367}
368
369/// Calculate simple asymmetry index
370pub fn calculate_asymmetry_index(snapshot: &OrderBookSnapshot, levels: usize) -> f64 {
371    let n_levels = levels.min(snapshot.bids.len()).min(snapshot.asks.len());
372    if n_levels == 0 {
373        return 0.0;
374    }
375
376    let mut bid_volume = 0.0;
377    let mut ask_volume = 0.0;
378
379    for i in 0..n_levels {
380        bid_volume += decimal_to_f64_or_nan(snapshot.bids[i].quantity);
381        ask_volume += decimal_to_f64_or_nan(snapshot.asks[i].quantity);
382    }
383
384    if bid_volume + ask_volume == 0.0 {
385        return 0.0;
386    }
387
388    (bid_volume - ask_volume) / (bid_volume + ask_volume)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::features::Level;
395    use rust_decimal_macros::dec;
396    use smallvec::smallvec;
397
398    fn create_test_snapshot() -> OrderBookSnapshot {
399        OrderBookSnapshot {
400            symbol: "TEST".into(),
401            timestamp_ns: 1_000_000_000,
402            bids: smallvec![
403                Level {
404                    price: dec!(99.9),
405                    quantity: dec!(100.0),
406                    order_count: 5
407                },
408                Level {
409                    price: dec!(99.8),
410                    quantity: dec!(150.0),
411                    order_count: 7
412                },
413                Level {
414                    price: dec!(99.7),
415                    quantity: dec!(200.0),
416                    order_count: 10
417                },
418            ],
419            asks: smallvec![
420                Level {
421                    price: dec!(100.1),
422                    quantity: dec!(80.0),
423                    order_count: 4
424                },
425                Level {
426                    price: dec!(100.2),
427                    quantity: dec!(120.0),
428                    order_count: 6
429                },
430                Level {
431                    price: dec!(100.3),
432                    quantity: dec!(180.0),
433                    order_count: 9
434                },
435            ],
436        }
437    }
438
439    #[test]
440    fn test_basic_asymmetry() {
441        let mut calculator = AsymmetryIndexCalculator::new(3, false, 0.95, 10);
442        let snapshot = create_test_snapshot();
443
444        let metrics = calculator.calculate(&snapshot);
445
446        // With more bid volume (450 vs 380), should be positive
447        assert!(metrics.volume_asymmetry > 0.0);
448
449        // More bid orders (22 vs 19), should be positive
450        assert!(metrics.order_count_asymmetry > 0.0);
451    }
452
453    #[test]
454    fn test_directional_pressure() {
455        let mut calculator = AsymmetryIndexCalculator::new(3, true, 0.95, 10);
456
457        // Create strongly bullish snapshot
458        let mut snapshot = create_test_snapshot();
459        snapshot.bids[0].quantity = dec!(500.0);
460        snapshot.bids[1].quantity = dec!(400.0);
461
462        let metrics = calculator.calculate(&snapshot);
463        assert!(matches!(
464            metrics.directional_pressure,
465            DirectionalPressure::StrongBullish
466        ));
467    }
468
469    #[test]
470    fn test_multi_timeframe() {
471        let mut analyzer = MultiTimeframeAsymmetry::new(3, 0.95);
472        let snapshot = create_test_snapshot();
473
474        let mtf_metrics = analyzer.update(&snapshot);
475
476        // All timeframes should initially show similar values
477        assert!(
478            (mtf_metrics.short_term.volume_asymmetry - mtf_metrics.medium_term.volume_asymmetry)
479                .abs()
480                < 0.1
481        );
482    }
483
484    #[test]
485    fn test_simple_asymmetry_function() {
486        let snapshot = create_test_snapshot();
487        let asymmetry = calculate_asymmetry_index(&snapshot, 3);
488
489        // Bid volume: 450, Ask volume: 380
490        // Expected: (450 - 380) / (450 + 380) ≈ 0.084
491        assert!((asymmetry - 0.084).abs() < 0.01);
492    }
493}