use crate::interbar_types::{InterBarFeatures, TradeSnapshot};
use foldhash::fast::FixedState;
use std::hash::{BuildHasher, Hash, Hasher};
pub const INTERBAR_FEATURE_CACHE_CAPACITY: u64 = 256;
fn hash_trade_window(lookback: &[&TradeSnapshot]) -> u64 {
if lookback.len() < 2 {
return lookback.len() as u64; }
let mut hasher = FixedState::default().build_hasher();
lookback.len().hash(&mut hasher);
let mut min_price = i64::MAX;
let mut max_price = i64::MIN;
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);
buy_count += (!trade.is_buyer_maker) as usize;
}
let price_range = (max_price - min_price) / 100;
price_range.hash(&mut hasher);
let avg_volume = if !lookback.is_empty() {
total_volume / lookback.len() as u64
} else {
0
};
avg_volume.hash(&mut hasher);
((buy_count * 100 / lookback.len()) as u8).hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct InterBarCacheKey {
pub trade_count: usize,
pub window_hash: u64,
}
impl InterBarCacheKey {
pub fn from_lookback(lookback: &[&TradeSnapshot]) -> Self {
Self {
trade_count: lookback.len(),
window_hash: hash_trade_window(lookback),
}
}
}
#[derive(Debug)]
pub struct InterBarFeatureCache {
cache: quick_cache::sync::Cache<InterBarCacheKey, InterBarFeatures>,
}
impl InterBarFeatureCache {
pub fn new() -> Self {
Self::with_capacity(INTERBAR_FEATURE_CACHE_CAPACITY)
}
pub fn with_capacity(capacity: u64) -> Self {
let cache = quick_cache::sync::Cache::new(capacity as usize);
Self { cache }
}
pub fn get(&self, key: &InterBarCacheKey) -> Option<InterBarFeatures> {
self.cache.get(key)
}
pub fn insert(&self, key: InterBarCacheKey, features: InterBarFeatures) {
self.cache.insert(key, features);
}
pub fn clear(&self) {
self.cache.clear();
}
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);
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);
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);
assert_eq!(key1.trade_count, key2.trade_count);
}
#[test]
fn test_cache_eviction_beyond_capacity() {
let capacity = 16u64;
let cache = InterBarFeatureCache::with_capacity(capacity);
let total = (capacity * 4) as usize;
for i in 0..total {
let key = InterBarCacheKey {
trade_count: i,
window_hash: i as u64 * 7919, };
cache.insert(key, InterBarFeatures::default());
}
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");
}
#[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);
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);
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);
assert!(
key.window_hash > 1,
"2-trade window should compute hash, not sentinel"
);
}
#[test]
fn test_hash_all_buyers_vs_all_sellers() {
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);
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);
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() {
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);
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));
}
}