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 <contact@alphabench.in>
175 lines
5.6 KiB
Rust
175 lines
5.6 KiB
Rust
//! 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<bool> {
|
|
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<bool> {
|
|
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<f64> {
|
|
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]);
|
|
}
|
|
}
|