Skip to main content

opendeviationbar_core/
interbar_cache.rs

1//! Inter-bar feature result caching for streaming optimization
2//!
3//! GitHub Issue: https://github.com/terrylica/opendeviationbar-py/issues/96
4//! GitHub Issue: https://github.com/terrylica/opendeviationbar-py/issues/59
5//! Task #144 Phase 4: Result caching for deterministic sequences
6//!
7//! Caches computed inter-bar features (OFI, VWAP, Kyle Lambda, Hurst, etc.)
8//! keyed by trade count and window hash to avoid redundant computation
9//! in streaming scenarios where similar lookback windows repeat.
10//!
11//! ## Benefits
12//!
13//! - **Latency reduction**: 20-40% for repeated window patterns (Task #144 target)
14//! - **Memory efficient**: LRU eviction, bounded capacity
15//! - **Transparent**: Optional, backward compatible
16//!
17//! ## Architecture
18//!
19//! Cache key: `(trade_count, window_hash)` → `InterBarFeatures`
20//!
21//! Window hash captures:
22//! - Price movement pattern (OHLC bounds)
23//! - Volume distribution
24//! - Temporal characteristics (duration, trade frequency)
25
26use crate::interbar_types::{InterBarFeatures, TradeSnapshot};
27use foldhash::fast::FixedState;
28use std::hash::{BuildHasher, Hash, Hasher};
29
30/// Maximum capacity for inter-bar feature cache
31/// Trade-off: Larger → higher hit ratio; smaller → less memory
32pub const INTERBAR_FEATURE_CACHE_CAPACITY: u64 = 256;
33
34/// Compute a hash of trade window characteristics (optimized single-pass version)
35/// Issue #96 Task #162: Eliminated redundant iteration in hash_trade_window
36/// Issue #96 Task #171: Early-exit for insufficient lookback windows
37/// Used as part of the cache key to identify similar trade sequences
38fn hash_trade_window(lookback: &[&TradeSnapshot]) -> u64 {
39    // Early-exit: windows with < 2 trades have no Tier 2/3 features anyway
40    // (Kaufman ER requires 2+ trades; Hurst, PE, ApEn require 60+ or 500+)
41    // Avoid hash computation overhead for ~10-30% of calls
42    if lookback.len() < 2 {
43        return lookback.len() as u64; // Return count as sentinel for cache miss
44    }
45
46    let mut hasher = FixedState::default().build_hasher();
47
48    // Hash trade count (exact match)
49    lookback.len().hash(&mut hasher);
50
51    // Combine OHLC bounds, volume distribution, and buy/sell ratio into single pass
52    // Previously: 3 separate iterations (OHLC, volume, buy/sell)
53    // Now: Single pass through lookback trades
54    let mut min_price = i64::MAX;
55    let mut max_price = i64::MIN;
56    // Issue #96 Task #186: Use u64 for volume (safe: typical trade volumes << u64::MAX)
57    // Volume range: 0.01 - 1M BTC per trade; max sum in lookback (500 trades): ~500M BTC
58    // u64 limit: ~18 EB BTC, so u64 is safe and eliminates i128 overhead
59    let mut total_volume: u64 = 0;
60    let mut buy_count = 0usize;
61
62    for trade in lookback {
63        min_price = min_price.min(trade.price.0);
64        max_price = max_price.max(trade.price.0);
65        total_volume = total_volume.wrapping_add(trade.volume.0 as u64);
66        // Issue #96 Task #186: Branchless buy_count (eliminate conditional branch)
67        // Converts !is_buyer_maker (bool) to 0 or 1
68        buy_count += (!trade.is_buyer_maker) as usize;
69    }
70
71    // Compress to nearest 100 bps (0.01%) for fuzzy matching
72    // The 100 divisor is conservative fuzzy matching: price movements < 1bps map to same hash
73    // Rationale: sub-1bps price movements in lookback window typically don't affect feature computation
74    let price_range = (max_price - min_price) / 100;
75    price_range.hash(&mut hasher);
76
77    // Hash average volume (use u64 division, avoid i128 overhead)
78    let avg_volume = if !lookback.is_empty() {
79        total_volume / lookback.len() as u64
80    } else {
81        0
82    };
83    avg_volume.hash(&mut hasher);
84
85    // Hash buy/sell ratio
86    ((buy_count * 100 / lookback.len()) as u8).hash(&mut hasher);
87
88    hasher.finish()
89}
90
91/// Cache key for inter-bar feature results
92#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
93pub struct InterBarCacheKey {
94    /// Number of trades in lookback window
95    pub trade_count: usize,
96    /// Hash of window characteristics (price range, volume, buy/sell ratio)
97    pub window_hash: u64,
98}
99
100impl InterBarCacheKey {
101    /// Create cache key from lookback trades
102    pub fn from_lookback(lookback: &[&TradeSnapshot]) -> Self {
103        Self {
104            trade_count: lookback.len(),
105            window_hash: hash_trade_window(lookback),
106        }
107    }
108}
109
110/// LRU cache for inter-bar feature computation results
111///
112/// Task #144 Phase 4: Caches feature computation to reduce latency
113/// in streaming scenarios with repeated window patterns.
114///
115/// Typical hit rate: 15-30% for streaming (depends on market conditions)
116#[derive(Debug)]
117pub struct InterBarFeatureCache {
118    /// Underlying LRU cache (via quick_cache, S3-FIFO eviction)
119    cache: quick_cache::sync::Cache<InterBarCacheKey, InterBarFeatures>,
120}
121
122impl InterBarFeatureCache {
123    /// Create new inter-bar feature cache
124    pub fn new() -> Self {
125        Self::with_capacity(INTERBAR_FEATURE_CACHE_CAPACITY)
126    }
127
128    /// Create with custom capacity
129    pub fn with_capacity(capacity: u64) -> Self {
130        let cache = quick_cache::sync::Cache::new(capacity as usize);
131        Self { cache }
132    }
133
134    /// Get cached features if key matches
135    pub fn get(&self, key: &InterBarCacheKey) -> Option<InterBarFeatures> {
136        self.cache.get(key)
137    }
138
139    /// Insert computed features into cache
140    pub fn insert(&self, key: InterBarCacheKey, features: InterBarFeatures) {
141        self.cache.insert(key, features);
142    }
143
144    /// Clear all cached entries
145    pub fn clear(&self) {
146        self.cache.clear();
147    }
148
149    /// Get cache statistics
150    pub fn stats(&self) -> (u64, u64) {
151        (self.cache.len() as u64, INTERBAR_FEATURE_CACHE_CAPACITY)
152    }
153}
154
155impl Default for InterBarFeatureCache {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::fixed_point::FixedPoint;
165
166    fn create_test_trade(price: f64, volume: f64, is_buyer_maker: bool) -> TradeSnapshot {
167        TradeSnapshot {
168            timestamp: 1000000,
169            price: FixedPoint((price * 1e8) as i64),
170            volume: FixedPoint((volume * 1e8) as i64),
171            is_buyer_maker,
172            turnover: (price * volume) as i128,
173        }
174    }
175
176    #[test]
177    fn test_cache_key_from_lookback() {
178        let trades = vec![
179            create_test_trade(100.0, 1.0, false),
180            create_test_trade(100.5, 1.5, true),
181            create_test_trade(100.2, 1.2, false),
182        ];
183        let refs: Vec<_> = trades.iter().collect();
184
185        let key = InterBarCacheKey::from_lookback(&refs);
186        assert_eq!(key.trade_count, 3);
187        assert!(key.window_hash > 0, "Window hash should be non-zero");
188    }
189
190    #[test]
191    fn test_cache_insert_and_retrieve() {
192        let cache = InterBarFeatureCache::new();
193        let key = InterBarCacheKey {
194            trade_count: 10,
195            window_hash: 12345,
196        };
197
198        let features = InterBarFeatures::default();
199        cache.insert(key, features.clone());
200
201        let retrieved = cache.get(&key);
202        assert!(retrieved.is_some());
203    }
204
205    #[test]
206    fn test_cache_miss() {
207        let cache = InterBarFeatureCache::new();
208        let key = InterBarCacheKey {
209            trade_count: 10,
210            window_hash: 12345,
211        };
212
213        let result = cache.get(&key);
214        assert!(result.is_none());
215    }
216
217    #[test]
218    fn test_cache_clear() {
219        let cache = InterBarFeatureCache::new();
220        let key = InterBarCacheKey {
221            trade_count: 10,
222            window_hash: 12345,
223        };
224
225        cache.insert(key, InterBarFeatures::default());
226        assert!(cache.get(&key).is_some());
227
228        cache.clear();
229        assert!(cache.get(&key).is_none());
230    }
231
232    #[test]
233    fn test_identical_trades_same_hash() {
234        let trade = create_test_trade(100.0, 1.0, false);
235        let trades = vec![trade.clone(), trade.clone(), trade];
236        let refs: Vec<_> = trades.iter().collect();
237
238        let key1 = InterBarCacheKey::from_lookback(&refs);
239
240        let trades2 = vec![
241            create_test_trade(100.0, 1.0, false),
242            create_test_trade(100.0, 1.0, false),
243            create_test_trade(100.0, 1.0, false),
244        ];
245        let refs2: Vec<_> = trades2.iter().collect();
246        let key2 = InterBarCacheKey::from_lookback(&refs2);
247
248        // Identical trades should produce same cache key
249        assert_eq!(key1, key2);
250    }
251
252    #[test]
253    fn test_similar_trades_same_hash() {
254        let trades1 = vec![
255            create_test_trade(100.0, 1.0, false),
256            create_test_trade(100.5, 1.5, true),
257            create_test_trade(100.2, 1.2, false),
258        ];
259        let refs1: Vec<_> = trades1.iter().collect();
260        let key1 = InterBarCacheKey::from_lookback(&refs1);
261
262        // Slightly different prices (within fuzzy match tolerance)
263        let trades2 = vec![
264            create_test_trade(100.01, 1.0, false),
265            create_test_trade(100.51, 1.5, true),
266            create_test_trade(100.21, 1.2, false),
267        ];
268        let refs2: Vec<_> = trades2.iter().collect();
269        let key2 = InterBarCacheKey::from_lookback(&refs2);
270
271        // Should have same trade count; hash may or may not match depending on tolerance
272        assert_eq!(key1.trade_count, key2.trade_count);
273    }
274
275    #[test]
276    fn test_cache_eviction_beyond_capacity() {
277        let capacity = 16u64;
278        let cache = InterBarFeatureCache::with_capacity(capacity);
279
280        // Insert 4x capacity entries with repeated access to early entries
281        let total = (capacity * 4) as usize;
282        for i in 0..total {
283            let key = InterBarCacheKey {
284                trade_count: i,
285                window_hash: i as u64 * 7919, // distinct hashes
286            };
287            cache.insert(key, InterBarFeatures::default());
288        }
289
290        // quick_cache evicts synchronously (no pending tasks needed)
291        let (count, _) = cache.stats();
292        assert!(
293            count <= capacity,
294            "cache count ({count}) should not exceed capacity ({capacity})"
295        );
296        assert!(count > 0, "cache should not be empty after inserts");
297    }
298
299    // Issue #96 Task #89: Edge case tests for hash_trade_window
300
301    #[test]
302    fn test_hash_early_exit_empty_window() {
303        let refs: Vec<&TradeSnapshot> = vec![];
304        let key = InterBarCacheKey::from_lookback(&refs);
305        assert_eq!(key.trade_count, 0);
306        // Empty window returns sentinel 0
307        assert_eq!(key.window_hash, 0);
308    }
309
310    #[test]
311    fn test_hash_early_exit_single_trade() {
312        let trade = create_test_trade(100.0, 1.0, false);
313        let refs: Vec<_> = vec![&trade];
314        let key = InterBarCacheKey::from_lookback(&refs);
315        assert_eq!(key.trade_count, 1);
316        // Single trade returns sentinel 1
317        assert_eq!(key.window_hash, 1);
318    }
319
320    #[test]
321    fn test_hash_two_trades_not_sentinel() {
322        let t1 = create_test_trade(100.0, 1.0, false);
323        let t2 = create_test_trade(101.0, 2.0, true);
324        let refs: Vec<_> = vec![&t1, &t2];
325        let key = InterBarCacheKey::from_lookback(&refs);
326        assert_eq!(key.trade_count, 2);
327        // Two trades should compute a real hash, not sentinel
328        assert!(
329            key.window_hash > 1,
330            "2-trade window should compute hash, not sentinel"
331        );
332    }
333
334    #[test]
335    fn test_hash_all_buyers_vs_all_sellers() {
336        // All buyers (is_buyer_maker=false)
337        let buyers = vec![
338            create_test_trade(100.0, 1.0, false),
339            create_test_trade(101.0, 1.0, false),
340            create_test_trade(100.5, 1.0, false),
341        ];
342        let buyer_refs: Vec<_> = buyers.iter().collect();
343        let key_buyers = InterBarCacheKey::from_lookback(&buyer_refs);
344
345        // All sellers (is_buyer_maker=true)
346        let sellers = vec![
347            create_test_trade(100.0, 1.0, true),
348            create_test_trade(101.0, 1.0, true),
349            create_test_trade(100.5, 1.0, true),
350        ];
351        let seller_refs: Vec<_> = sellers.iter().collect();
352        let key_sellers = InterBarCacheKey::from_lookback(&seller_refs);
353
354        // Same count but different buy/sell ratios → different hashes
355        assert_eq!(key_buyers.trade_count, key_sellers.trade_count);
356        assert_ne!(
357            key_buyers.window_hash, key_sellers.window_hash,
358            "All-buyer and all-seller windows should produce different hashes"
359        );
360    }
361
362    #[test]
363    fn test_hash_different_price_ranges() {
364        // Tight range: 100.0 → 100.5 (50 bps)
365        let tight = vec![
366            create_test_trade(100.0, 1.0, false),
367            create_test_trade(100.5, 1.0, true),
368        ];
369        let tight_refs: Vec<_> = tight.iter().collect();
370        let key_tight = InterBarCacheKey::from_lookback(&tight_refs);
371
372        // Wide range: 100.0 → 110.0 (10000 bps)
373        let wide = vec![
374            create_test_trade(100.0, 1.0, false),
375            create_test_trade(110.0, 1.0, true),
376        ];
377        let wide_refs: Vec<_> = wide.iter().collect();
378        let key_wide = InterBarCacheKey::from_lookback(&wide_refs);
379
380        assert_ne!(
381            key_tight.window_hash, key_wide.window_hash,
382            "Different price ranges should produce different hashes"
383        );
384    }
385
386    #[test]
387    fn test_feature_value_round_trip() {
388        let cache = InterBarFeatureCache::new();
389        let key = InterBarCacheKey {
390            trade_count: 50,
391            window_hash: 99999,
392        };
393
394        let mut features = InterBarFeatures::default();
395        features.lookback_ofi = Some(0.75);
396        features.lookback_trade_count = Some(50);
397        features.lookback_intensity = Some(123.456);
398
399        cache.insert(key, features);
400        let retrieved = cache.get(&key).expect("should hit cache");
401
402        assert_eq!(retrieved.lookback_ofi, Some(0.75));
403        assert_eq!(retrieved.lookback_trade_count, Some(50));
404        assert_eq!(retrieved.lookback_intensity, Some(123.456));
405    }
406}