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>
247 lines
8.6 KiB
Rust
247 lines
8.6 KiB
Rust
//! Tick-level feature extraction functions.
|
||
//!
|
||
//! All functions accept parallel arrays (one element per tick) and return a
|
||
//! Vec<f64> 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<f64> {
|
||
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<f64> {
|
||
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<f64> {
|
||
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<f64> {
|
||
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::<f64>() / log_rets.len() as f64;
|
||
let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
|
||
/ (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<f64> {
|
||
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<f64> {
|
||
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);
|
||
}
|
||
}
|