From fb3a2dda25ab780e080ce34d8e4844e111180cf3 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:47:08 +0530 Subject: [PATCH] feat(tick): add tick signal generation and feature extraction functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tick signal generation (src/signals/tick_signals.rs): - tick_momentum_entry: O(N) single-pass entry signal array from spread/BSI/return gates with cooldown enforcement; replaces the Python O(N×120) entry-check loop - tick_momentum_exit: time-based (EOD) exit bool array from tick timestamps Tick feature extraction (src/indicators/tick_features.rs): - tick_spread_pct: (ask-bid)/mid * 100, element-wise - buy_sell_imbalance_delta: per-tick delta BSI from Zerodha cumulative session totals — fixes the ~0.95 all-day artefact from raw cumulative sums - return_window: lookback return over configurable time window, binary search O(N log N); returns NaN where history insufficient (no silent pass-through) - realized_vol_rolling: rolling stddev of log-returns as realized vol proxy - oi_position_pct: OI position within day's high/low range [0, 100] - tick_velocity: rolling ticks/min over configurable window Python bindings: compute_tick_entry_signals, compute_tick_exit_signals, tick_spread_pct, buy_sell_imbalance_delta, return_window, realized_vol_rolling, oi_position_pct, tick_velocity — all with numpy array I/O and default args. 15 new Rust unit tests (7 signal, 8 feature); 153 total, 0 failed. Co-Authored-By: porcelaincode --- python/raptorbt/__init__.py | 20 +++ src/indicators/mod.rs | 5 + src/indicators/tick_features.rs | 246 ++++++++++++++++++++++++++++++++ src/lib.rs | 12 ++ src/python/bindings.rs | 186 ++++++++++++++++++++++++ src/signals/mod.rs | 2 + src/signals/tick_signals.rs | 174 ++++++++++++++++++++++ 7 files changed, 645 insertions(+) create mode 100644 src/indicators/tick_features.rs create mode 100644 src/signals/tick_signals.rs diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py index 6ef137d..b32d814 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -32,6 +32,16 @@ from raptorbt._raptorbt import ( batch_spread_backtest, # Monte Carlo simulation simulate_portfolio_mc, + # Tick signal functions + compute_tick_entry_signals, + compute_tick_exit_signals, + # Tick feature functions + tick_spread_pct, + buy_sell_imbalance_delta, + return_window, + realized_vol_rolling, + oi_position_pct, + tick_velocity, # Indicator functions sma, ema, @@ -72,6 +82,16 @@ __all__ = [ "batch_spread_backtest", # Monte Carlo simulation "simulate_portfolio_mc", + # Tick signal functions + "compute_tick_entry_signals", + "compute_tick_exit_signals", + # Tick feature functions + "tick_spread_pct", + "buy_sell_imbalance_delta", + "return_window", + "realized_vol_rolling", + "oi_position_pct", + "tick_velocity", # Indicator functions "sma", "ema", diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs index acb40b7..408274c 100644 --- a/src/indicators/mod.rs +++ b/src/indicators/mod.rs @@ -6,6 +6,7 @@ pub mod momentum; pub mod rolling; pub mod strength; +pub mod tick_features; pub mod trend; pub mod volatility; pub mod volume; @@ -13,6 +14,10 @@ pub mod volume; pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult}; pub use rolling::{rolling_max, rolling_min}; pub use strength::adx; +pub use tick_features::{ + buy_sell_imbalance_delta, oi_position_pct, realized_vol_rolling, return_window, spread_pct, + tick_velocity, +}; pub use trend::{ema, sma, supertrend, SupertrendResult}; pub use volatility::{atr, bollinger_bands, BollingerBandsResult}; pub use volume::{obv, vwap}; diff --git a/src/indicators/tick_features.rs b/src/indicators/tick_features.rs new file mode 100644 index 0000000..d9a6855 --- /dev/null +++ b/src/indicators/tick_features.rs @@ -0,0 +1,246 @@ +//! 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 98a2e52..e96da7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,18 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { // Register Monte Carlo simulation m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?; + // Register tick signal functions + m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?; + + // Register tick feature functions + m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?; + // Register indicator functions m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?; diff --git a/src/python/bindings.rs b/src/python/bindings.rs index 82c6ce2..91e0c0d 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -1135,6 +1135,192 @@ pub fn run_tick_backtest<'py>( Ok(convert_result(result)) } +// ============================================================================ +// Tick Signal Functions +// ============================================================================ + +/// Compute tick momentum entry signals from per-tick feature arrays. +/// +/// All input arrays must have the same length N. Returns a bool array of length N +/// where True indicates a valid entry tick (all gates passed, not in cooldown). +/// +/// Gates (each can be disabled by setting threshold to 0.0): +/// - spread_pct[i] <= spread_pct_max +/// - bsi_delta[i] >= bsi_min (0.0 = disabled) +/// - |return_1m[i]| >= return_1m_min_abs (0.0 = disabled; NaN always fails) +/// - cooldown_ticks between consecutive entries +/// +/// return_direction: +1 for long (needs positive return_1m), -1 for short. +#[pyfunction] +#[pyo3(signature = ( + spread_pct, + bsi_delta, + return_1m, + spread_pct_max = 5.0, + bsi_min = 0.0, + return_1m_min_abs = 0.0, + return_direction = 1_i8, + cooldown_ticks = 10_usize, +))] +pub fn compute_tick_entry_signals<'py>( + py: Python<'py>, + spread_pct: PyReadonlyArray1, + bsi_delta: PyReadonlyArray1, + return_1m: PyReadonlyArray1, + spread_pct_max: f64, + bsi_min: f64, + return_1m_min_abs: f64, + return_direction: i8, + cooldown_ticks: usize, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_entry( + &numpy_to_vec_f64(spread_pct), + &numpy_to_vec_f64(bsi_delta), + &numpy_to_vec_f64(return_1m), + spread_pct_max, + bsi_min, + return_1m_min_abs, + return_direction, + cooldown_ticks, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +/// Compute time-based exit signals (EOD / session-end). +/// +/// Sets exit[i] = True for every tick with timestamp >= eod_exit_time_ns. +/// Set eod_exit_time_ns = 0 to disable (returns all False). +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick (int64 array). +#[pyfunction] +#[pyo3(signature = (timestamps_ns, eod_exit_time_ns = 0_i64))] +pub fn compute_tick_exit_signals<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + eod_exit_time_ns: i64, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_exit( + &numpy_to_vec_i64(timestamps_ns), + eod_exit_time_ns, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +// ============================================================================ +// Tick Feature Functions +// ============================================================================ + +/// Per-tick bid/ask spread as percentage of mid price. +/// Returns 0.0 where both bid and ask are zero. +#[pyfunction] +pub fn tick_spread_pct<'py>( + py: Python<'py>, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::spread_pct(&numpy_to_vec_f64(bid), &numpy_to_vec_f64(ask)), + )) +} + +/// Per-tick delta BSI from Zerodha cumulative session totals. +/// +/// buy_qty_cumulative / sell_qty_cumulative must be the raw cumulative running sums +/// from Zerodha (NOT already-converted deltas). Returns [0, 1] per tick; 0.5 = neutral. +#[pyfunction] +pub fn buy_sell_imbalance_delta<'py>( + py: Python<'py>, + buy_qty_cumulative: PyReadonlyArray1, + sell_qty_cumulative: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::buy_sell_imbalance_delta( + &numpy_to_vec_f64(buy_qty_cumulative), + &numpy_to_vec_f64(sell_qty_cumulative), + ), + )) +} + +/// Per-tick lookback return over a time window. +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick. +/// Returns NaN for ticks without sufficient history. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 60.0))] +pub fn return_window<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::return_window( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Rolling realized volatility proxy: stddev of log-returns over a time window (as %). +/// Returns NaN for ticks without at least 2 data points in the window. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 300.0))] +pub fn realized_vol_rolling<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::realized_vol_rolling( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Per-tick OI position within the day's high/low range: [0, 100]. +/// Returns NaN where oi_day_high <= oi_day_low. +#[pyfunction] +pub fn oi_position_pct<'py>( + py: Python<'py>, + oi: PyReadonlyArray1, + oi_day_high: f64, + oi_day_low: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::oi_position_pct( + &numpy_to_vec_f64(oi), + oi_day_high, + oi_day_low, + ), + )) +} + +/// Rolling tick velocity: ticks per minute over the preceding window_seconds. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, window_seconds = 60.0))] +pub fn tick_velocity<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::tick_velocity( + &numpy_to_vec_i64(timestamps_ns), + window_seconds, + ), + )) +} + // ============================================================================ // Indicator Functions // ============================================================================ diff --git a/src/signals/mod.rs b/src/signals/mod.rs index e16b71a..fb7b401 100644 --- a/src/signals/mod.rs +++ b/src/signals/mod.rs @@ -5,6 +5,8 @@ pub mod expression; pub mod processor; pub mod synchronizer; +pub mod tick_signals; pub use processor::SignalProcessor; pub use synchronizer::{SignalSynchronizer, SyncMode}; +pub use tick_signals::{tick_momentum_entry, tick_momentum_exit}; diff --git a/src/signals/tick_signals.rs b/src/signals/tick_signals.rs new file mode 100644 index 0000000..5e57931 --- /dev/null +++ b/src/signals/tick_signals.rs @@ -0,0 +1,174 @@ +//! Tick-level signal generation for momentum entry/exit. +//! +//! Converts precomputed feature arrays (one scalar per tick) into entry and +//! exit boolean arrays that can be fed directly into `run_tick_backtest`. +//! +//! All functions are O(N) single-pass — no backward linear search, no nested +//! loops. The return_1m feature array must be precomputed by the caller +//! (via `tick_features::return_window` or equivalent). + +/// Generate momentum entry signals from per-tick feature arrays. +/// +/// All input slices must have the same length N. +/// +/// Rules applied in order (a failing rule sets entry[i] = false): +/// 1. spread gate: `spread_pct[i] <= spread_pct_max` +/// 2. BSI gate: if `bsi_min > 0.0`, `bsi_delta[i] >= bsi_min` +/// 3. return gate: if `return_1m_min_abs > 0.0`, direction-aligned +/// `return_1m[i]` must have `abs >= return_1m_min_abs` and correct sign. +/// NaN return_1m always fails the gate. +/// 4. cooldown: after each entry, suppress the next `cooldown_ticks` ticks. +/// +/// `return_direction`: +1 for long (return_1m must be positive), -1 for short +/// (return_1m must be negative). +pub fn tick_momentum_entry( + spread_pct: &[f64], + bsi_delta: &[f64], + return_1m: &[f64], + spread_pct_max: f64, + bsi_min: f64, + return_1m_min_abs: f64, + return_direction: i8, + cooldown_ticks: usize, +) -> Vec { + let n = spread_pct.len(); + let mut entries = vec![false; n]; + let mut cooldown_until: usize = 0; + + for i in 0..n { + if i < cooldown_until { + continue; + } + + // Spread gate + if spread_pct[i] > spread_pct_max { + continue; + } + + // BSI delta gate (disabled when bsi_min == 0.0) + if bsi_min > 0.0 { + let b = if i < bsi_delta.len() { bsi_delta[i] } else { continue }; + if b < bsi_min { + continue; + } + } + + // 1-minute return gate (disabled when return_1m_min_abs == 0.0) + if return_1m_min_abs > 0.0 { + let r = if i < return_1m.len() { return_1m[i] } else { continue }; + if r.is_nan() { + continue; + } + let abs_r = r.abs(); + if abs_r < return_1m_min_abs { + continue; + } + // Direction alignment: long needs positive return, short needs negative + if return_direction > 0 && r < 0.0 { + continue; + } + if return_direction < 0 && r > 0.0 { + continue; + } + } + + entries[i] = true; + cooldown_until = i + 1 + cooldown_ticks; + } + + entries +} + +/// Generate time-based exit signals (EOD / session-end). +/// +/// Sets exit[i] = true for every tick at or after `eod_exit_time_ns`. +/// When `eod_exit_time_ns == 0` all exits are false (disabled). +/// +/// `timestamps_ns`: nanoseconds-since-epoch timestamp for each tick. +pub fn tick_momentum_exit(timestamps_ns: &[i64], eod_exit_time_ns: i64) -> Vec { + let n = timestamps_ns.len(); + if eod_exit_time_ns == 0 { + return vec![false; n]; + } + timestamps_ns + .iter() + .map(|&ts| ts >= eod_exit_time_ns) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_return_1m(vals: &[f64]) -> Vec { + vals.to_vec() + } + + #[test] + fn test_entry_spread_gate() { + // All spreads above max → no entries + let spread = vec![3.0, 4.0, 6.0]; + let bsi = vec![0.6, 0.7, 0.8]; + let ret = vec![1.0, 1.0, 1.0]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 2.0, 0.0, 0.0, 1, 0); + assert_eq!(entries, vec![false, false, false]); + } + + #[test] + fn test_entry_bsi_gate() { + let spread = vec![1.0, 1.0, 1.0]; + let bsi = vec![0.3, 0.6, 0.4]; // only index 1 passes bsi_min=0.5 + let ret = vec![0.5, 0.5, 0.5]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.5, 0.0, 1, 0); + assert_eq!(entries, vec![false, true, false]); + } + + #[test] + fn test_entry_return_gate_long() { + let spread = vec![1.0, 1.0, 1.0, 1.0]; + let bsi = vec![0.6, 0.6, 0.6, 0.6]; + // positive, positive, too small, negative + let ret = vec![0.5, 1.0, 0.1, -0.5]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, 1, 0); + assert_eq!(entries, vec![true, true, false, false]); + } + + #[test] + fn test_entry_return_gate_short() { + let spread = vec![1.0, 1.0, 1.0]; + let bsi = vec![0.6, 0.6, 0.6]; + // negative enough, positive (fails direction), nan + let ret = vec![-0.5, 0.5, f64::NAN]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, -1, 0); + assert_eq!(entries, vec![true, false, false]); + } + + #[test] + fn test_entry_cooldown() { + // cooldown_ticks=2: after entry at i=0, next eligible at i=3 + let spread = vec![1.0; 6]; + let bsi = vec![0.6; 6]; + let ret = vec![0.0; 6]; + let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.0, 1, 2); + assert!(entries[0]); + assert!(!entries[1]); + assert!(!entries[2]); + assert!(entries[3]); + assert!(!entries[4]); + assert!(!entries[5]); + } + + #[test] + fn test_exit_disabled() { + let ts = vec![1_000_000_i64, 2_000_000, 3_000_000]; + let exits = tick_momentum_exit(&ts, 0); + assert_eq!(exits, vec![false, false, false]); + } + + #[test] + fn test_exit_eod_fires() { + let ts = vec![1_000_i64, 2_000, 3_000, 4_000]; + let exits = tick_momentum_exit(&ts, 3_000); + assert_eq!(exits, vec![false, false, true, true]); + } +}