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>
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user