//! Tick-level feature extraction functions. //! //! All functions accept parallel arrays (one element per tick) and return a //! Vec of the same length. NaN is used where the feature is undefined //! (e.g. insufficient history for a lookback window). //! //! These are building blocks for the signal generation layer — compute features //! once on the full tick window, then pass the resulting arrays to //! `tick_signals::tick_momentum_entry`. /// Per-tick bid/ask spread as a percentage of the mid price. /// /// Returns 0.0 where both bid and ask are zero. pub fn spread_pct(bid: &[f64], ask: &[f64]) -> Vec { bid.iter() .zip(ask.iter()) .map(|(&b, &a)| { let mid = (b + a) / 2.0; if mid > 0.0 { (a - b) / mid * 100.0 } else { 0.0 } }) .collect() } /// Per-tick delta BSI from Zerodha cumulative session totals. /// /// Zerodha's `total_buy_qty` / `total_sell_qty` are running sums that grow /// monotonically from market open. Computing BSI from raw cumulative values /// yields ~0.95 for the whole day (artefact of early-session buy-side dominance). /// /// This function computes the imbalance of the most recent tick's activity only: /// `bsi[i] = Δbuy[i] / (Δbuy[i] + Δsell[i])` where `Δbuy[i] = max(0, buy[i] - buy[i-1])` /// /// Returns 0.5 (neutral) where the total delta is zero (no activity). pub fn buy_sell_imbalance_delta( buy_qty_cumulative: &[f64], sell_qty_cumulative: &[f64], ) -> Vec { let n = buy_qty_cumulative.len(); let mut out = vec![0.5_f64; n]; for i in 1..n { let db = (buy_qty_cumulative[i] - buy_qty_cumulative[i - 1]).max(0.0); let ds = (sell_qty_cumulative[i] - sell_qty_cumulative[i - 1]).max(0.0); let total = db + ds; if total > 0.0 { out[i] = db / total; } } out } /// Per-tick lookback return over a fixed time window. /// /// For each tick i, finds the latest tick whose timestamp is at most /// `timestamps_ns[i] - window_seconds * 1e9` and computes: /// `(ltp[i] - ltp_ref) / ltp_ref * 100` /// /// Returns `f64::NAN` for ticks where no reference tick exists (start of series /// or insufficient history). /// /// Uses binary search → O(N log N) total. pub fn return_window(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec { let n = timestamps_ns.len(); let window_ns = (window_seconds * 1_000_000_000.0) as i64; let mut out = vec![f64::NAN; n]; for i in 0..n { let cutoff = timestamps_ns[i] - window_ns; // Binary search for the last index with ts <= cutoff let pos = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff); // pos is the first index > cutoff; we want pos.saturating_sub(1) if pos > 0 { let ref_idx = pos - 1; let ltp_ref = ltp[ref_idx]; if ltp_ref > 0.0 { out[i] = (ltp[i] - ltp_ref) / ltp_ref * 100.0; } } } out } /// Rolling realized volatility proxy: annualized stddev of log returns. /// /// For each tick i, computes stddev of log-returns over all ticks within /// the preceding `window_seconds`. Returns `f64::NAN` if fewer than 2 ticks /// in the window. /// /// O(N²) worst case but typical windows are short (60–300 s at ~80 ticks/min /// = 80–400 ticks), making the inner loop fast in practice. pub fn realized_vol_rolling(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec { let n = timestamps_ns.len(); let window_ns = (window_seconds * 1_000_000_000.0) as i64; let mut out = vec![f64::NAN; n]; for i in 1..n { let cutoff = timestamps_ns[i] - window_ns; // Find the first tick inside the window let start = timestamps_ns[..i].partition_point(|&ts| ts < cutoff); // We need log returns from start..=i let count = i - start; if count < 1 { continue; } let mut log_rets = Vec::with_capacity(count); for j in (start + 1)..=i { if ltp[j - 1] > 0.0 { log_rets.push((ltp[j] / ltp[j - 1]).ln()); } } if log_rets.len() < 2 { continue; } let mean = log_rets.iter().sum::() / log_rets.len() as f64; let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::() / (log_rets.len() - 1) as f64; out[i] = variance.sqrt() * 100.0; // as percentage of price } out } /// Per-tick OI position within the day's high/low range. /// /// Returns `(oi[i] - oi_day_low) / (oi_day_high - oi_day_low) * 100` ∈ [0, 100]. /// Returns `f64::NAN` where `oi_day_high <= oi_day_low`. pub fn oi_position_pct(oi: &[f64], oi_day_high: f64, oi_day_low: f64) -> Vec { let range = oi_day_high - oi_day_low; if range <= 0.0 { return vec![f64::NAN; oi.len()]; } oi.iter() .map(|&o| (o - oi_day_low) / range * 100.0) .collect() } /// Rolling tick velocity: number of ticks per minute in the preceding window. /// /// For each tick i, counts ticks in (timestamps_ns[i] - window_seconds*1e9, timestamps_ns[i]]. /// Returns 0.0 for the first tick. pub fn tick_velocity(timestamps_ns: &[i64], window_seconds: f64) -> Vec { let n = timestamps_ns.len(); let window_ns = (window_seconds * 1_000_000_000.0) as i64; let mut out = vec![0.0_f64; n]; for i in 1..n { let cutoff = timestamps_ns[i] - window_ns; let start = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff); let count = (i - start + 1) as f64; // include current tick let minutes = window_seconds / 60.0; out[i] = if minutes > 0.0 { count / minutes } else { 0.0 }; } out } #[cfg(test)] mod tests { use super::*; #[test] fn test_spread_pct_basic() { let bid = vec![100.0, 200.0]; let ask = vec![101.0, 202.0]; let s = spread_pct(&bid, &ask); // (101-100)/100.5 * 100 ≈ 0.995 assert!((s[0] - 0.9950248756218905).abs() < 1e-9); // (202-200)/201 * 100 ≈ 0.995 assert!((s[1] - 0.9950248756218905).abs() < 1e-9); } #[test] fn test_spread_pct_zero_bid_ask() { let bid = vec![0.0]; let ask = vec![0.0]; let s = spread_pct(&bid, &ask); assert_eq!(s[0], 0.0); } #[test] fn test_bsi_delta_basic() { // Cumulative: buy grows by 100, sell by 0 → bsi = 1.0 let buy = vec![1000.0, 1100.0, 1100.0, 1150.0]; let sell = vec![800.0, 800.0, 850.0, 850.0]; let bsi = buy_sell_imbalance_delta(&buy, &sell); assert_eq!(bsi[0], 0.5); // first tick always neutral assert_eq!(bsi[1], 1.0); // all buy assert_eq!(bsi[2], 0.0); // all sell assert_eq!(bsi[3], 1.0); // all buy } #[test] fn test_bsi_delta_no_activity() { // No change → neutral 0.5 let buy = vec![1000.0, 1000.0]; let sell = vec![800.0, 800.0]; let bsi = buy_sell_imbalance_delta(&buy, &sell); assert_eq!(bsi[1], 0.5); } #[test] fn test_return_window_basic() { // Ticks at 0s, 30s, 61s, 90s (nanoseconds) let sec = 1_000_000_000_i64; let ts = vec![0, 30 * sec, 61 * sec, 90 * sec]; let ltp = vec![100.0, 102.0, 101.0, 105.0]; let ret = return_window(&ts, <p, 60.0); // ts[0]: no history → NAN assert!(ret[0].is_nan()); // ts[1] at 30s: no tick <= -30s → NAN assert!(ret[1].is_nan()); // ts[2] at 61s: cutoff = 1s, ts[0]=0 ≤ 1s → ref = ltp[0]=100.0 // (101 - 100) / 100 * 100 = 1.0 assert!((ret[2] - 1.0).abs() < 1e-9); // ts[3] at 90s: cutoff = 30s, ts[1]=30s ≤ 30s → ref = ltp[1]=102.0 // (105 - 102) / 102 * 100 ≈ 2.941 assert!((ret[3] - (3.0 / 102.0 * 100.0)).abs() < 1e-9); } #[test] fn test_oi_position_pct() { let oi = vec![50.0, 100.0, 150.0]; let result = oi_position_pct(&oi, 200.0, 0.0); assert_eq!(result, vec![25.0, 50.0, 75.0]); } #[test] fn test_oi_position_pct_no_range() { let oi = vec![100.0, 100.0]; let result = oi_position_pct(&oi, 100.0, 100.0); assert!(result[0].is_nan()); assert!(result[1].is_nan()); } #[test] fn test_tick_velocity_basic() { // 4 ticks at 0s, 10s, 20s, 30s; window=60s let sec = 1_000_000_000_i64; let ts = vec![0, 10 * sec, 20 * sec, 30 * sec]; let vel = tick_velocity(&ts, 60.0); // At i=3 (30s): ticks in (−30s, 30s] = all 4 → 4 ticks / 1 min = 4.0 assert_eq!(vel[0], 0.0); assert!((vel[3] - 4.0).abs() < 1e-9); } }