opendeviationbar-core 13.70.3

Core open deviation bar construction algorithm with temporal integrity guarantees
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Inter-bar feature result caching for streaming optimization
//!
//! GitHub Issue: https://github.com/terrylica/opendeviationbar-py/issues/96
//! GitHub Issue: https://github.com/terrylica/opendeviationbar-py/issues/59
//! Task #144 Phase 4: Result caching for deterministic sequences
//!
//! Caches computed inter-bar features (OFI, VWAP, Kyle Lambda, Hurst, etc.)
//! keyed by trade count and window hash to avoid redundant computation
//! in streaming scenarios where similar lookback windows repeat.
//!
//! ## Benefits
//!
//! - **Latency reduction**: 20-40% for repeated window patterns (Task #144 target)
//! - **Memory efficient**: LRU eviction, bounded capacity
//! - **Transparent**: Optional, backward compatible
//!
//! ## Architecture
//!
//! Cache key: `(trade_count, window_hash)` → `InterBarFeatures`
//!
//! Window hash captures:
//! - Price movement pattern (OHLC bounds)
//! - Volume distribution
//! - Temporal characteristics (duration, trade frequency)

use crate::interbar_types::{InterBarFeatures, TradeSnapshot};
use foldhash::fast::FixedState;
use std::hash::{BuildHasher, Hash, Hasher};

/// Maximum capacity for inter-bar feature cache
/// Trade-off: Larger → higher hit ratio; smaller → less memory
pub const INTERBAR_FEATURE_CACHE_CAPACITY: u64 = 256;

/// Compute a hash of trade window characteristics (optimized single-pass version)
/// Issue #96 Task #162: Eliminated redundant iteration in hash_trade_window
/// Issue #96 Task #171: Early-exit for insufficient lookback windows
/// Used as part of the cache key to identify similar trade sequences
fn hash_trade_window(lookback: &[&TradeSnapshot]) -> u64 {
    // Early-exit: windows with < 2 trades have no Tier 2/3 features anyway
    // (Kaufman ER requires 2+ trades; Hurst, PE, ApEn require 60+ or 500+)
    // Avoid hash computation overhead for ~10-30% of calls
    if lookback.len() < 2 {
        return lookback.len() as u64; // Return count as sentinel for cache miss
    }

    let mut hasher = FixedState::default().build_hasher();

    // Hash trade count (exact match)
    lookback.len().hash(&mut hasher);

    // Combine OHLC bounds, volume distribution, and buy/sell ratio into single pass
    // Previously: 3 separate iterations (OHLC, volume, buy/sell)
    // Now: Single pass through lookback trades
    let mut min_price = i64::MAX;
    let mut max_price = i64::MIN;
    // Issue #96 Task #186: Use u64 for volume (safe: typical trade volumes << u64::MAX)
    // Volume range: 0.01 - 1M BTC per trade; max sum in lookback (500 trades): ~500M BTC
    // u64 limit: ~18 EB BTC, so u64 is safe and eliminates i128 overhead
    let mut total_volume: u64 = 0;
    let mut buy_count = 0usize;

    for trade in lookback {
        min_price = min_price.min(trade.price.0);
        max_price = max_price.max(trade.price.0);
        total_volume = total_volume.wrapping_add(trade.volume.0 as u64);
        // Issue #96 Task #186: Branchless buy_count (eliminate conditional branch)
        // Converts !is_buyer_maker (bool) to 0 or 1
        buy_count += (!trade.is_buyer_maker) as usize;
    }

    // Compress to nearest 100 bps (0.01%) for fuzzy matching
    // The 100 divisor is conservative fuzzy matching: price movements < 1bps map to same hash
    // Rationale: sub-1bps price movements in lookback window typically don't affect feature computation
    let price_range = (max_price - min_price) / 100;
    price_range.hash(&mut hasher);

    // Hash average volume (use u64 division, avoid i128 overhead)
    let avg_volume = if !lookback.is_empty() {
        total_volume / lookback.len() as u64
    } else {
        0
    };
    avg_volume.hash(&mut hasher);

    // Hash buy/sell ratio
    ((buy_count * 100 / lookback.len()) as u8).hash(&mut hasher);

    hasher.finish()
}

/// Cache key for inter-bar feature results
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct InterBarCacheKey {
    /// Number of trades in lookback window
    pub trade_count: usize,
    /// Hash of window characteristics (price range, volume, buy/sell ratio)
    pub window_hash: u64,
}

impl InterBarCacheKey {
    /// Create cache key from lookback trades
    pub fn from_lookback(lookback: &[&TradeSnapshot]) -> Self {
        Self {
            trade_count: lookback.len(),
            window_hash: hash_trade_window(lookback),
        }
    }
}

/// LRU cache for inter-bar feature computation results
///
/// Task #144 Phase 4: Caches feature computation to reduce latency
/// in streaming scenarios with repeated window patterns.
///
/// Typical hit rate: 15-30% for streaming (depends on market conditions)
#[derive(Debug)]
pub struct InterBarFeatureCache {
    /// Underlying LRU cache (via quick_cache, S3-FIFO eviction)
    cache: quick_cache::sync::Cache<InterBarCacheKey, InterBarFeatures>,
}

impl InterBarFeatureCache {
    /// Create new inter-bar feature cache
    pub fn new() -> Self {
        Self::with_capacity(INTERBAR_FEATURE_CACHE_CAPACITY)
    }

    /// Create with custom capacity
    pub fn with_capacity(capacity: u64) -> Self {
        let cache = quick_cache::sync::Cache::new(capacity as usize);
        Self { cache }
    }

    /// Get cached features if key matches
    pub fn get(&self, key: &InterBarCacheKey) -> Option<InterBarFeatures> {
        self.cache.get(key)
    }

    /// Insert computed features into cache
    pub fn insert(&self, key: InterBarCacheKey, features: InterBarFeatures) {
        self.cache.insert(key, features);
    }

    /// Clear all cached entries
    pub fn clear(&self) {
        self.cache.clear();
    }

    /// Get cache statistics
    pub fn stats(&self) -> (u64, u64) {
        (self.cache.len() as u64, INTERBAR_FEATURE_CACHE_CAPACITY)
    }
}

impl Default for InterBarFeatureCache {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fixed_point::FixedPoint;

    fn create_test_trade(price: f64, volume: f64, is_buyer_maker: bool) -> TradeSnapshot {
        TradeSnapshot {
            timestamp: 1000000,
            price: FixedPoint((price * 1e8) as i64),
            volume: FixedPoint((volume * 1e8) as i64),
            is_buyer_maker,
            turnover: (price * volume) as i128,
        }
    }

    #[test]
    fn test_cache_key_from_lookback() {
        let trades = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(100.5, 1.5, true),
            create_test_trade(100.2, 1.2, false),
        ];
        let refs: Vec<_> = trades.iter().collect();

        let key = InterBarCacheKey::from_lookback(&refs);
        assert_eq!(key.trade_count, 3);
        assert!(key.window_hash > 0, "Window hash should be non-zero");
    }

    #[test]
    fn test_cache_insert_and_retrieve() {
        let cache = InterBarFeatureCache::new();
        let key = InterBarCacheKey {
            trade_count: 10,
            window_hash: 12345,
        };

        let features = InterBarFeatures::default();
        cache.insert(key, features.clone());

        let retrieved = cache.get(&key);
        assert!(retrieved.is_some());
    }

    #[test]
    fn test_cache_miss() {
        let cache = InterBarFeatureCache::new();
        let key = InterBarCacheKey {
            trade_count: 10,
            window_hash: 12345,
        };

        let result = cache.get(&key);
        assert!(result.is_none());
    }

    #[test]
    fn test_cache_clear() {
        let cache = InterBarFeatureCache::new();
        let key = InterBarCacheKey {
            trade_count: 10,
            window_hash: 12345,
        };

        cache.insert(key, InterBarFeatures::default());
        assert!(cache.get(&key).is_some());

        cache.clear();
        assert!(cache.get(&key).is_none());
    }

    #[test]
    fn test_identical_trades_same_hash() {
        let trade = create_test_trade(100.0, 1.0, false);
        let trades = vec![trade.clone(), trade.clone(), trade];
        let refs: Vec<_> = trades.iter().collect();

        let key1 = InterBarCacheKey::from_lookback(&refs);

        let trades2 = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(100.0, 1.0, false),
            create_test_trade(100.0, 1.0, false),
        ];
        let refs2: Vec<_> = trades2.iter().collect();
        let key2 = InterBarCacheKey::from_lookback(&refs2);

        // Identical trades should produce same cache key
        assert_eq!(key1, key2);
    }

    #[test]
    fn test_similar_trades_same_hash() {
        let trades1 = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(100.5, 1.5, true),
            create_test_trade(100.2, 1.2, false),
        ];
        let refs1: Vec<_> = trades1.iter().collect();
        let key1 = InterBarCacheKey::from_lookback(&refs1);

        // Slightly different prices (within fuzzy match tolerance)
        let trades2 = vec![
            create_test_trade(100.01, 1.0, false),
            create_test_trade(100.51, 1.5, true),
            create_test_trade(100.21, 1.2, false),
        ];
        let refs2: Vec<_> = trades2.iter().collect();
        let key2 = InterBarCacheKey::from_lookback(&refs2);

        // Should have same trade count; hash may or may not match depending on tolerance
        assert_eq!(key1.trade_count, key2.trade_count);
    }

    #[test]
    fn test_cache_eviction_beyond_capacity() {
        let capacity = 16u64;
        let cache = InterBarFeatureCache::with_capacity(capacity);

        // Insert 4x capacity entries with repeated access to early entries
        let total = (capacity * 4) as usize;
        for i in 0..total {
            let key = InterBarCacheKey {
                trade_count: i,
                window_hash: i as u64 * 7919, // distinct hashes
            };
            cache.insert(key, InterBarFeatures::default());
        }

        // quick_cache evicts synchronously (no pending tasks needed)
        let (count, _) = cache.stats();
        assert!(
            count <= capacity,
            "cache count ({count}) should not exceed capacity ({capacity})"
        );
        assert!(count > 0, "cache should not be empty after inserts");
    }

    // Issue #96 Task #89: Edge case tests for hash_trade_window

    #[test]
    fn test_hash_early_exit_empty_window() {
        let refs: Vec<&TradeSnapshot> = vec![];
        let key = InterBarCacheKey::from_lookback(&refs);
        assert_eq!(key.trade_count, 0);
        // Empty window returns sentinel 0
        assert_eq!(key.window_hash, 0);
    }

    #[test]
    fn test_hash_early_exit_single_trade() {
        let trade = create_test_trade(100.0, 1.0, false);
        let refs: Vec<_> = vec![&trade];
        let key = InterBarCacheKey::from_lookback(&refs);
        assert_eq!(key.trade_count, 1);
        // Single trade returns sentinel 1
        assert_eq!(key.window_hash, 1);
    }

    #[test]
    fn test_hash_two_trades_not_sentinel() {
        let t1 = create_test_trade(100.0, 1.0, false);
        let t2 = create_test_trade(101.0, 2.0, true);
        let refs: Vec<_> = vec![&t1, &t2];
        let key = InterBarCacheKey::from_lookback(&refs);
        assert_eq!(key.trade_count, 2);
        // Two trades should compute a real hash, not sentinel
        assert!(
            key.window_hash > 1,
            "2-trade window should compute hash, not sentinel"
        );
    }

    #[test]
    fn test_hash_all_buyers_vs_all_sellers() {
        // All buyers (is_buyer_maker=false)
        let buyers = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(101.0, 1.0, false),
            create_test_trade(100.5, 1.0, false),
        ];
        let buyer_refs: Vec<_> = buyers.iter().collect();
        let key_buyers = InterBarCacheKey::from_lookback(&buyer_refs);

        // All sellers (is_buyer_maker=true)
        let sellers = vec![
            create_test_trade(100.0, 1.0, true),
            create_test_trade(101.0, 1.0, true),
            create_test_trade(100.5, 1.0, true),
        ];
        let seller_refs: Vec<_> = sellers.iter().collect();
        let key_sellers = InterBarCacheKey::from_lookback(&seller_refs);

        // Same count but different buy/sell ratios → different hashes
        assert_eq!(key_buyers.trade_count, key_sellers.trade_count);
        assert_ne!(
            key_buyers.window_hash, key_sellers.window_hash,
            "All-buyer and all-seller windows should produce different hashes"
        );
    }

    #[test]
    fn test_hash_different_price_ranges() {
        // Tight range: 100.0 → 100.5 (50 bps)
        let tight = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(100.5, 1.0, true),
        ];
        let tight_refs: Vec<_> = tight.iter().collect();
        let key_tight = InterBarCacheKey::from_lookback(&tight_refs);

        // Wide range: 100.0 → 110.0 (10000 bps)
        let wide = vec![
            create_test_trade(100.0, 1.0, false),
            create_test_trade(110.0, 1.0, true),
        ];
        let wide_refs: Vec<_> = wide.iter().collect();
        let key_wide = InterBarCacheKey::from_lookback(&wide_refs);

        assert_ne!(
            key_tight.window_hash, key_wide.window_hash,
            "Different price ranges should produce different hashes"
        );
    }

    #[test]
    fn test_feature_value_round_trip() {
        let cache = InterBarFeatureCache::new();
        let key = InterBarCacheKey {
            trade_count: 50,
            window_hash: 99999,
        };

        let mut features = InterBarFeatures::default();
        features.lookback_ofi = Some(0.75);
        features.lookback_trade_count = Some(50);
        features.lookback_intensity = Some(123.456);

        cache.insert(key, features);
        let retrieved = cache.get(&key).expect("should hit cache");

        assert_eq!(retrieved.lookback_ofi, Some(0.75));
        assert_eq!(retrieved.lookback_trade_count, Some(50));
        assert_eq!(retrieved.lookback_intensity, Some(123.456));
    }
}