Files
raptorbt/src/signals/tick_signals.rs
T
porcelaincodeandporcelaincode fb3a2dda25 feat(tick): add tick signal generation and feature extraction functions
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>
2026-06-03 21:47:08 +05:30

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]);
}
}