From 514c235f1c99b7d80f7e28bfb99b1110dde80550 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:26:51 +0530 Subject: [PATCH 1/6] feat(core): add TickData struct, TimeExit reason, compute_backtest_metrics pub fn - TickData: parallel tick arrays (timestamps, ltp, bid, ask, buy_qty_delta, sell_qty_delta, oi) with len/is_empty helpers; callers must pre-convert Zerodha cumulative totals to per-tick deltas before passing - ExitReason::TimeExit: max hold time exceeded variant for tick backtest - compute_backtest_metrics: pub free fn wrapping PortfolioEngine::calculate_metrics so non-OHLCV strategies can produce identical metrics without duplication Co-Authored-By: Claude Sonnet 4.6 --- src/core/types.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/portfolio/engine.rs | 19 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/core/types.rs b/src/core/types.rs index dab37b6..4fa1b49 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -104,6 +104,44 @@ impl OhlcvData { } } +/// Raw tick data series for tick-level backtesting. +/// +/// All fields are parallel arrays of length N (one entry per tick). +/// `buy_qty_delta` and `sell_qty_delta` must be per-tick deltas, not +/// cumulative session totals — callers are responsible for converting +/// Zerodha-style running sums before passing them here. +#[derive(Debug, Clone)] +pub struct TickData { + /// Nanoseconds-since-epoch timestamp for each tick. + pub timestamps: Vec, + /// Last traded price at each tick. + pub ltp: Vec, + /// Best bid price at each tick (0.0 if unavailable). + pub bid: Vec, + /// Best ask price at each tick (0.0 if unavailable). + pub ask: Vec, + /// Per-tick buy quantity delta (not cumulative). + pub buy_qty_delta: Vec, + /// Per-tick sell quantity delta (not cumulative). + pub sell_qty_delta: Vec, + /// Open interest at each tick (0 if unavailable). + pub oi: Vec, +} + +impl TickData { + /// Number of ticks. + #[inline] + pub fn len(&self) -> usize { + self.ltp.len() + } + + /// Whether the series is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.ltp.is_empty() + } +} + /// Compiled trading signals from strategy. #[derive(Debug, Clone)] pub struct CompiledSignals { @@ -214,6 +252,8 @@ pub enum ExitReason { EndOfData, /// Option expiry settlement. Settlement, + /// Max hold time exceeded (tick backtest). + TimeExit, } /// Backtest configuration. diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs index 198ea3b..e3aff20 100644 --- a/src/portfolio/engine.rs +++ b/src/portfolio/engine.rs @@ -761,6 +761,25 @@ impl PortfolioEngine { } } +/// Compute `BacktestMetrics` from pre-built curves and trade list. +/// +/// Exposed as a standalone function so non-OHLCV strategies (e.g. tick backtest) +/// can produce identical metrics without duplicating the calculation logic. +pub fn compute_backtest_metrics( + equity_curve: &[f64], + drawdown_curve: &[f64], + returns: &[f64], + trades: &[Trade], + initial_capital: f64, +) -> BacktestMetrics { + // Delegate to a throwaway engine instance — avoids duplicating the logic. + let engine = PortfolioEngine::new(BacktestConfig { + initial_capital, + ..Default::default() + }); + engine.calculate_metrics(equity_curve, drawdown_curve, returns, trades, &StreamingMetrics::new()) +} + #[cfg(test)] mod tests { use super::*; From fa6959bb994d2f0f2d30e2d6ce4d72b913b63181 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:27:54 +0530 Subject: [PATCH 2/6] =?UTF-8?q?feat(tick):=20add=20run=5Ftick=5Fbacktest?= =?UTF-8?q?=20=E2=80=94=20tick-native=20simulation=20engine,=20bump=20to?= =?UTF-8?q?=200.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full tick-level backtest path that operates on raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas, oi) plus caller-computed entry/exit signal bool arrays. Entry fills at ask+slippage; stop/target checked against ltp on every tick; max-hold-seconds time exit; cooldown between entries. Produces identical BacktestMetrics as run_single_backtest via the new compute_backtest_metrics free fn. 5 Rust unit tests: target-hit, stop-hit, time-exit, multi-trade-with-cooldown, empty-ticks edge case — all pass (138 total, 0 failed). Co-Authored-By: porcelaincode --- Cargo.lock | 2 +- Cargo.toml | 2 +- pyproject.toml | 2 +- python/raptorbt/__init__.py | 4 +- src/lib.rs | 1 + src/python/bindings.rs | 86 +++++++++ src/strategies/mod.rs | 2 + src/strategies/tick.rs | 359 ++++++++++++++++++++++++++++++++++++ 8 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 src/strategies/tick.rs diff --git a/Cargo.lock b/Cargo.lock index 5aa5f03..7692619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "raptorbt" -version = "0.3.4" +version = "0.4.0" dependencies = [ "approx", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 2d8c493..0674301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "raptorbt" -version = "0.3.4" +version = "0.4.0" edition = "2021" description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." authors = ["Alphabench "] diff --git a/pyproject.toml b/pyproject.toml index 71b7948..fd8a9b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "raptorbt" -version = "0.3.4" +version = "0.4.0" description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." readme = "README.md" requires-python = ">=3.10" diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py index 3b9045b..6ef137d 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -26,6 +26,7 @@ from raptorbt._raptorbt import ( run_pairs_backtest, run_multi_backtest, run_spread_backtest, + run_tick_backtest, # Batch backtest PyBatchSpreadItem, batch_spread_backtest, @@ -46,7 +47,7 @@ from raptorbt._raptorbt import ( rolling_max, ) -__version__ = "0.3.4" +__version__ = "0.4.0" __all__ = [ # Config classes @@ -65,6 +66,7 @@ __all__ = [ "run_pairs_backtest", "run_multi_backtest", "run_spread_backtest", + "run_tick_backtest", # Batch backtest "PyBatchSpreadItem", "batch_spread_backtest", diff --git a/src/lib.rs b/src/lib.rs index 38b3ffc..98a2e52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_tick_backtest, m)?)?; // Register batch spread backtest m.add_class::()?; diff --git a/src/python/bindings.rs b/src/python/bindings.rs index 83e4c92..82c6ce2 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -21,6 +21,7 @@ use crate::strategies::single::SingleBacktest; use crate::strategies::spreads::{ LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, }; +use crate::strategies::tick::{TickBacktest, TickBacktestConfig}; use super::numpy_bridge::*; @@ -1049,6 +1050,91 @@ pub fn run_multi_backtest<'py>( Ok(convert_result(result)) } +/// Run tick-level backtest on a single instrument. +/// +/// All arrays must be the same length N (one element per tick). +/// `buy_qty_delta` and `sell_qty_delta` must already be per-tick deltas — +/// pass the difference from the previous tick, not Zerodha's cumulative totals. +/// `entries` / `exits` are caller-computed boolean signal arrays. +/// +/// Returns a `PyBacktestResult` with the same fields as `run_single_backtest`. +#[pyfunction] +#[pyo3(signature = ( + timestamps, + ltp, + bid, + ask, + buy_qty_delta, + sell_qty_delta, + oi, + entries, + exits, + symbol = "TICK", + initial_capital = 100_000.0, + fees = 0.001, + slippage = 0.0, + stop_loss_pct = 5.0, + take_profit_pct = 10.0, + max_hold_seconds = 1800_u64, + entry_cooldown_ticks = 10_usize, + max_trades = 50_usize, +))] +pub fn run_tick_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + ltp: PyReadonlyArray1, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, + buy_qty_delta: PyReadonlyArray1, + sell_qty_delta: PyReadonlyArray1, + oi: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + symbol: &str, + initial_capital: f64, + fees: f64, + slippage: f64, + stop_loss_pct: f64, + take_profit_pct: f64, + max_hold_seconds: u64, + entry_cooldown_ticks: usize, + max_trades: usize, +) -> PyResult { + let tick_data = crate::core::types::TickData { + timestamps: numpy_to_vec_i64(timestamps), + ltp: numpy_to_vec_f64(ltp), + bid: numpy_to_vec_f64(bid), + ask: numpy_to_vec_f64(ask), + buy_qty_delta: numpy_to_vec_f64(buy_qty_delta), + sell_qty_delta: numpy_to_vec_f64(sell_qty_delta), + oi: numpy_to_vec_f64(oi), + }; + + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + let config = TickBacktestConfig { + base: crate::core::types::BacktestConfig { + initial_capital, + fees, + slippage, + stop: crate::core::types::StopConfig::None, + target: crate::core::types::TargetConfig::None, + upon_bar_close: false, + }, + stop_loss_pct, + take_profit_pct, + max_hold_seconds, + entry_cooldown_ticks, + max_trades, + }; + + let backtest = TickBacktest::new(config); + let result = backtest.run(&tick_data, &entry_signals, &exit_signals, symbol); + + Ok(convert_result(result)) +} + // ============================================================================ // Indicator Functions // ============================================================================ diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs index 4c5f920..c1fb5ee 100644 --- a/src/strategies/mod.rs +++ b/src/strategies/mod.rs @@ -6,6 +6,7 @@ pub mod options; pub mod pairs; pub mod single; pub mod spreads; +pub mod tick; pub use basket::BasketBacktest; pub use multi::MultiStrategyBacktest; @@ -15,3 +16,4 @@ pub use single::SingleBacktest; pub use spreads::{ LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, }; +pub use tick::{TickBacktest, TickBacktestConfig}; diff --git a/src/strategies/tick.rs b/src/strategies/tick.rs new file mode 100644 index 0000000..1ef6335 --- /dev/null +++ b/src/strategies/tick.rs @@ -0,0 +1,359 @@ +//! Tick-level backtest implementation. +//! +//! Accepts raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas) plus +//! parallel entry/exit signal arrays, then simulates each trade to +//! stop-loss / take-profit / max-hold-time exit at full tick resolution. +//! +//! This is the right path for intraday options momentum strategies where the +//! exact fill tick matters. Do not resample to bars before calling this — +//! bar resampling discards intra-bar path information and makes scalping +//! strategies unbacktestable. + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, ExitReason, Price, TickData, Timestamp, Trade, +}; +use crate::portfolio::engine::compute_backtest_metrics; + +/// Configuration specific to tick backtests. +#[derive(Debug, Clone)] +pub struct TickBacktestConfig { + /// Shared execution config (capital, fees, slippage). + pub base: BacktestConfig, + /// Stop-loss as percentage of entry price (e.g. 5.0 = 5%). + pub stop_loss_pct: f64, + /// Take-profit as percentage of entry price (e.g. 10.0 = 10%). + pub take_profit_pct: f64, + /// Maximum hold time in seconds. 0 = no time limit. + pub max_hold_seconds: u64, + /// Minimum ticks between entries (cooldown). Prevents overlapping positions. + pub entry_cooldown_ticks: usize, + /// Maximum trades to simulate (bounds runtime for large windows). + pub max_trades: usize, +} + +impl Default for TickBacktestConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 1800, + entry_cooldown_ticks: 10, + max_trades: 50, + } + } +} + +/// Tick-level backtest runner. +pub struct TickBacktest { + config: TickBacktestConfig, +} + +impl TickBacktest { + pub fn new(config: TickBacktestConfig) -> Self { + Self { config } + } + + /// Run the tick backtest. + /// + /// `ticks` — raw tick data (ltp, bid, ask, per-tick qty deltas) + /// `entries` — parallel bool array: true at ticks where a new long entry is allowed + /// `exits` — parallel bool array: true at ticks where an open position must close + /// `symbol` — instrument label used in trade records + pub fn run( + &self, + ticks: &TickData, + entries: &[bool], + exits: &[bool], + symbol: &str, + ) -> BacktestResult { + let n = ticks.len(); + assert_eq!(n, entries.len(), "ticks and entries must have same length"); + assert_eq!(n, exits.len(), "ticks and exits must have same length"); + + let slippage_frac = self.config.base.slippage; // e.g. 0.0005 = 0.05% + let fee_frac = self.config.base.fees; // e.g. 0.001 = 0.1% + let stop_frac = self.config.stop_loss_pct / 100.0; + let target_frac = self.config.take_profit_pct / 100.0; + let max_hold_ns: i64 = self.config.max_hold_seconds as i64 * 1_000_000_000; + + let mut trades: Vec = Vec::new(); + let mut trade_id: u64 = 0; + + // Position state + let mut in_position = false; + let mut entry_idx: usize = 0; + let mut entry_price: Price = 0.0; + let mut entry_time: Timestamp = 0; + let mut stop_level: Price = 0.0; + let mut target_level: Price = 0.0; + let mut entry_fees: f64 = 0.0; + let mut cooldown_until: usize = 0; + + for i in 0..n { + let ltp = ticks.ltp[i]; + let bid = if ticks.bid[i] > 0.0 { ticks.bid[i] } else { ltp }; + let ask = if ticks.ask[i] > 0.0 { ticks.ask[i] } else { ltp }; + let ts = ticks.timestamps[i]; + + if in_position { + // Check time exit first (hard deadline) + let time_exit = max_hold_ns > 0 && (ts - entry_time) >= max_hold_ns; + + // Check explicit exit signal + let signal_exit = exits[i]; + + // Check stop and target against ltp (tick-exact, no OHLC lookahead) + let stop_hit = ltp <= stop_level; + let target_hit = ltp >= target_level; + + let (exit_price, reason) = if stop_hit { + // Fill at stop level (not ltp — avoid worse-than-stop fills) + let fill = stop_level * (1.0 - slippage_frac); + (fill, ExitReason::StopLoss) + } else if target_hit { + let fill = target_level * (1.0 - slippage_frac); + (fill, ExitReason::TakeProfit) + } else if time_exit || signal_exit { + let fill = bid * (1.0 - slippage_frac); + let reason = if time_exit { ExitReason::TimeExit } else { ExitReason::Signal }; + (fill, reason) + } else if i == n - 1 { + // End of data — force close at bid + let fill = bid * (1.0 - slippage_frac); + (fill, ExitReason::EndOfData) + } else { + continue; + }; + + let exit_fees = exit_price * fee_frac; + let gross_pnl = (exit_price - entry_price) * 1.0; // qty=1; caller scales by lot_size + let net_pnl = gross_pnl - entry_fees - exit_fees; + let return_pct = net_pnl / entry_price * 100.0; + + trades.push(Trade { + id: trade_id, + symbol: symbol.to_string(), + entry_idx, + exit_idx: i, + entry_price, + exit_price, + size: 1.0, + direction: crate::core::types::Direction::Long, + pnl: net_pnl, + return_pct, + entry_time, + exit_time: ts, + fees: entry_fees + exit_fees, + exit_reason: reason, + }); + + trade_id += 1; + in_position = false; + cooldown_until = i + self.config.entry_cooldown_ticks; + + if trades.len() >= self.config.max_trades { + break; + } + } else { + // Not in position — check for entry + if i < cooldown_until { + continue; + } + if !entries[i] { + continue; + } + if ask <= 0.0 { + continue; + } + + entry_price = ask * (1.0 + slippage_frac); + entry_fees = entry_price * fee_frac; + entry_idx = i; + entry_time = ts; + stop_level = entry_price * (1.0 - stop_frac); + target_level = entry_price * (1.0 + target_frac); + in_position = true; + } + } + + Self::build_result(trades, self.config.base.initial_capital, symbol) + } + + fn build_result(trades: Vec, initial_capital: f64, _symbol: &str) -> BacktestResult { + if trades.is_empty() { + let metrics = BacktestMetrics { + start_value: initial_capital, + end_value: initial_capital, + ..Default::default() + }; + return BacktestResult::new(metrics, vec![initial_capital], vec![0.0], vec![], vec![]); + } + + // Build per-trade equity and return curves (one point per trade close). + let mut equity = initial_capital; + let mut equity_curve = vec![initial_capital]; + let mut returns = Vec::with_capacity(trades.len()); + + for t in &trades { + let prev = *equity_curve.last().unwrap(); + equity += t.pnl; + equity_curve.push(equity); + let ret = if prev > 0.0 { (equity - prev) / prev } else { 0.0 }; + returns.push(ret); + } + + // Drawdown curve over equity points (percentage, positive = drawdown). + let mut peak = initial_capital; + let drawdown_curve: Vec = equity_curve + .iter() + .map(|&e| { + if e > peak { + peak = e; + } + if peak > 0.0 { (peak - e) / peak * 100.0 } else { 0.0 } + }) + .collect(); + + let metrics = + compute_backtest_metrics(&equity_curve, &drawdown_curve, &returns, &trades, initial_capital); + + BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::BacktestConfig; + + fn make_ticks(n: usize, base_price: f64, trend: f64) -> TickData { + let ltp: Vec = (0..n).map(|i| base_price + i as f64 * trend).collect(); + let bid: Vec = ltp.iter().map(|p| p - 0.5).collect(); + let ask: Vec = ltp.iter().map(|p| p + 0.5).collect(); + TickData { + timestamps: (0..n as i64).map(|i| i * 1_000_000_000).collect(), // 1s apart + ltp, + bid, + ask, + buy_qty_delta: vec![100.0; n], + sell_qty_delta: vec![80.0; n], + oi: vec![0.0; n], + } + } + + #[test] + fn test_target_hit() { + // 100 ticks trending up — entry at tick 0, target should be hit + let ticks = make_ticks(100, 100.0, 0.5); // price goes 100 → 149.5 + let mut entries = vec![false; 100]; + entries[0] = true; + let exits = vec![false; 100]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 0, // no time limit + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::TakeProfit); + assert!(result.trades[0].pnl > 0.0); + } + + #[test] + fn test_stop_hit() { + // 100 ticks trending down — entry at tick 0, stop should be hit + let ticks = make_ticks(100, 100.0, -0.5); // price goes 100 → 50.5 + let mut entries = vec![false; 100]; + entries[0] = true; + let exits = vec![false; 100]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 20.0, + max_hold_seconds: 0, + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss); + assert!(result.trades[0].pnl < 0.0); + } + + #[test] + fn test_time_exit() { + // Flat price — neither stop nor target hit, time exit should fire + let ticks = make_ticks(200, 100.0, 0.0); + let mut entries = vec![false; 200]; + entries[0] = true; + let exits = vec![false; 200]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 50.0, // very wide, won't hit + take_profit_pct: 50.0, + max_hold_seconds: 10, // 10 ticks at 1s each + entry_cooldown_ticks: 5, + max_trades: 10, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert_eq!(result.trades.len(), 1); + assert_eq!(result.trades[0].exit_reason, ExitReason::TimeExit); + } + + #[test] + fn test_multiple_trades_with_cooldown() { + let ticks = make_ticks(200, 100.0, 0.2); + // Entry every 20 ticks + let entries: Vec = (0..200).map(|i| i % 20 == 0).collect(); + let exits = vec![false; 200]; + + let config = TickBacktestConfig { + base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() }, + stop_loss_pct: 5.0, + take_profit_pct: 10.0, + max_hold_seconds: 0, + entry_cooldown_ticks: 5, + max_trades: 20, + }; + + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &entries, &exits, "TEST"); + + assert!(result.trades.len() > 1); + assert!(result.metrics.total_trades > 1); + } + + #[test] + fn test_empty_ticks_returns_empty_result() { + let ticks = TickData { + timestamps: vec![], + ltp: vec![], + bid: vec![], + ask: vec![], + buy_qty_delta: vec![], + sell_qty_delta: vec![], + oi: vec![], + }; + let config = TickBacktestConfig::default(); + let bt = TickBacktest::new(config); + let result = bt.run(&ticks, &[], &[], "TEST"); + assert_eq!(result.trades.len(), 0); + assert_eq!(result.metrics.total_trades, 0); + } +} From 3420edee2b719d8229afea9199ef13dd5a4a27ba Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:28:00 +0530 Subject: [PATCH 3/6] fix(indicators): correct test_macd signal line warmup assertion signal_start = (slow_period-1) + (signal_period-1) = 24+8 = 32, so signal_line[33] is the first valid value, not NaN. Test was asserting [33].is_nan() which was always wrong. Co-Authored-By: porcelaincode --- src/indicators/momentum.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/indicators/momentum.rs b/src/indicators/momentum.rs index b351c5c..30ff934 100644 --- a/src/indicators/momentum.rs +++ b/src/indicators/momentum.rs @@ -276,9 +276,9 @@ mod tests { assert!(result.macd_line[24].is_nan()); assert!(!result.macd_line[25].is_nan()); - // Signal line should be valid later - assert!(result.signal_line[33].is_nan()); - assert!(!result.signal_line[34].is_nan()); + // Signal line starts at index slow_period-1 + signal_period-1 = 25+8 = 33 + assert!(result.signal_line[32].is_nan()); + assert!(!result.signal_line[33].is_nan()); } #[test] From fb3a2dda25ab780e080ce34d8e4844e111180cf3 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:47:08 +0530 Subject: [PATCH 4/6] 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]); + } +} From 3a9f7564adadfa7bfb31b8748a61ca626800b6a6 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:47:15 +0530 Subject: [PATCH 5/6] docs(tick): document v0.4.0 tick-level processing API in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Strategy Types §7 (Tick-Level Backtest) covering: - run_tick_backtest usage with annotated example - compute_tick_entry_signals / compute_tick_exit_signals - All 6 tick feature functions with usage snippets - Zerodha cumulative-sum handling note (buy_sell_imbalance_delta vs run_tick_backtest pre-conversion) Add v0.4.0 changelog entry listing all 11 new public API additions (TickData struct, TimeExit, run_tick_backtest, 2 signal functions, 6 feature functions, compute_backtest_metrics pub fn). Co-Authored-By: porcelaincode --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/README.md b/README.md index 3f02fd4..343480b 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,74 @@ for strategy_id, result in results: print(f"{strategy_id}: {result.metrics.total_return_pct:.2f}%") ``` +### 7. Tick-Level Backtest + +Simulate intraday strategies at full tick resolution — no bar resampling, no intra-bar path approximation. Designed for options momentum, scalping, and any setup where the exact fill tick matters. + +```python +import numpy as np +import raptorbt + +# Raw tick arrays (one element per tick, same length N) +# buy_qty_delta / sell_qty_delta must be per-tick deltas, NOT Zerodha cumulative sums +result = raptorbt.run_tick_backtest( + timestamps=timestamps_ns, # int64 nanoseconds-since-epoch + ltp=ltp_arr, # last traded price + bid=bid_arr, + ask=ask_arr, + buy_qty_delta=buy_delta, # pre-converted from cumulative: np.diff(buy_cum).clip(0) + sell_qty_delta=sell_delta, + oi=oi_arr, + entries=entry_signals, # bool array — True where entry is allowed + exits=exit_signals, # bool array — True where position should exit + symbol="NIFTY26APR24600PE", + initial_capital=100_000.0, + fees=0.001, + slippage=0.0005, + stop_loss_pct=5.0, + take_profit_pct=10.0, + max_hold_seconds=1800, # 30-minute maximum hold + entry_cooldown_ticks=10, # minimum ticks between entries + max_trades=50, +) + +print(f"trades: {result.metrics.total_trades}") +print(f"profit_factor: {result.metrics.profit_factor:.2f}") +print(f"win_rate: {result.metrics.win_rate_pct:.1f}%") +``` + +#### Tick Signal & Feature Helpers + +Precompute entry/exit signal arrays and tick microstructure features before calling `run_tick_backtest`: + +```python +# Signal arrays +entries = raptorbt.compute_tick_entry_signals( + spread_pct=raptorbt.tick_spread_pct(bid, ask), + bsi_delta=raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum), # pass raw cumulative + return_1m=raptorbt.return_window(timestamps_ns, ltp, window_seconds=60.0), + spread_pct_max=3.0, + bsi_min=0.55, # minimum buy-side delta fraction + return_1m_min_abs=0.3, # minimum 1-min return % (abs) + return_direction=1, # +1 long, -1 short + cooldown_ticks=10, +) +exits = raptorbt.compute_tick_exit_signals( + timestamps_ns=timestamps_ns, + eod_exit_time_ns=eod_ns, # force exit at/after this timestamp; 0 = disabled +) + +# Feature arrays (all return Vec of same length as input) +spread = raptorbt.tick_spread_pct(bid, ask) # (ask-bid)/mid * 100 +bsi = raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum) # delta BSI per tick +ret_1m = raptorbt.return_window(ts_ns, ltp, 60.0) # 1-min lookback return % +vol = raptorbt.realized_vol_rolling(ts_ns, ltp, 300.0) # 5-min realized vol % +oi_pos = raptorbt.oi_position_pct(oi, oi_day_high, oi_day_low) # [0, 100] +velocity = raptorbt.tick_velocity(ts_ns, 60.0) # ticks/min over last 60s +``` + +**Important for Zerodha data:** `total_buy_qty` and `total_sell_qty` from KiteTicker are cumulative session running sums, not per-tick values. Pass them as-is to `buy_sell_imbalance_delta` (it computes deltas internally). For `run_tick_backtest`, convert first: `buy_delta = np.diff(buy_cum, prepend=0).clip(min=0)`. + --- ## Metrics @@ -997,6 +1065,23 @@ MIT License - see [LICENSE](LICENSE) for details. ## Changelog +### v0.4.0 + +**Tick-level backtesting — full tick resolution, no bar resampling.** + +- Add `TickData` struct — parallel arrays of `timestamps`, `ltp`, `bid`, `ask`, `buy_qty_delta`, `sell_qty_delta`, `oi` (one element per tick). Callers must pre-convert Zerodha cumulative session totals to per-tick deltas before passing. +- Add `ExitReason::TimeExit` — max hold-time exceeded exit for tick strategies. +- Add `run_tick_backtest` — tick-native simulation engine. Entry fills at ask+slippage; stop/target checked against ltp on every tick (not OHLC approximation); max-hold-seconds time exit; configurable cooldown between entries. Returns the same `PyBacktestResult` / 27-metric `PyBacktestMetrics` as all other strategy types. +- Add `compute_tick_entry_signals` — compute momentum entry bool array from precomputed feature arrays (spread gate, delta BSI gate, 1-min return gate, cooldown enforcement). O(N) single pass. +- Add `compute_tick_exit_signals` — time-based (EOD) exit bool array from tick timestamps. +- Add `tick_spread_pct` — per-tick bid/ask spread as percentage of mid price. +- Add `buy_sell_imbalance_delta` — per-tick delta BSI from Zerodha cumulative running sums. Fixes the raw-cumulative BSI artefact (~0.95 all day regardless of order flow). +- Add `return_window` — per-tick lookback return over a configurable time window using binary search (O(N log N)). Returns NaN where history is insufficient — correctly gates the entry filter rather than silently passing. +- Add `realized_vol_rolling` — rolling realized volatility proxy (stddev of log-returns) over a time window. +- Add `oi_position_pct` — OI position within the day's high/low range, per tick: [0, 100]. +- Add `tick_velocity` — rolling tick count per minute over a configurable time window. +- Expose `compute_backtest_metrics` as a public free function in `portfolio::engine` — non-OHLCV strategy types can produce identical metrics without duplicating the calculation logic. + ### v0.3.4 - Add single-leg option spread types: `LongCall`, `LongPut`, `NakedCall`, `NakedPut` to `SpreadType` enum From fc0c756203c9cb66c81abd8a3dd12831ab5ff6b3 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:52:30 +0530 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20remove=20all=20VectorBT=20referenc?= =?UTF-8?q?es=20=E2=80=94=20raptorbt=20stands=20on=20its=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: remove VectorBT Comparison section and TOC entry, rewrite Overview/Performance as standalone benchmarks, clean metric-mapping table reference, update feature list to 7 strategy types including tick - Cargo.toml / pyproject.toml: rewrite description without VectorBT mention - __init__.py: rewrite module docstring without comparative framing - Rust comments (engine.rs, position.rs, signals/processor.rs, core/types.rs, python/bindings.rs): replace "matching VectorBT behavior/formula/methodology" with plain descriptions of what the code does Co-Authored-By: porcelaincode --- Cargo.toml | 2 +- README.md | 151 ++++++++---------------------------- pyproject.toml | 2 +- python/raptorbt/__init__.py | 9 ++- src/core/types.rs | 2 +- src/portfolio/engine.rs | 15 ++-- src/portfolio/position.rs | 2 +- src/python/bindings.rs | 2 +- src/signals/processor.rs | 8 +- 9 files changed, 51 insertions(+), 142 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0674301..ad64390 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "raptorbt" version = "0.4.0" edition = "2021" -description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." +description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint." authors = ["Alphabench "] license = "MIT" repository = "https://github.com/alphabench/raptorbt" diff --git a/README.md b/README.md index 343480b..7966877 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Blazing-fast backtesting for the modern quant.** -RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It serves as a drop-in replacement for VectorBT — delivering **HFT-grade compute efficiency** with full metric parity. +RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. Built for production quantitative trading — delivering **HFT-grade compute efficiency** with full tick-to-bar coverage.

5,800x faster · 45x smaller · 100% deterministic @@ -58,7 +58,6 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team. - [Metrics](#metrics) - [Indicators](#indicators) - [Stop-Loss & Take-Profit](#stop-loss--take-profit) -- [VectorBT Comparison](#vectorbt-comparison) - [API Reference](#api-reference) - [Building from Source](#building-from-source) - [Testing](#testing) @@ -67,23 +66,24 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team. ## Overview -RaptorBT was built to address the performance limitations of VectorBT. Benchmarked by the Alphabench team: +RaptorBT is benchmarked by the Alphabench team on Apple Silicon M-series: -| Metric | VectorBT | RaptorBT | Improvement | -| ----------------------------- | ------------------- | ------------ | ------------------------- | -| **Disk Footprint** | ~450MB | <10MB | **45x smaller** | -| **Startup Latency** | 200-600ms | <10ms | **20-60x faster** | -| **Backtest Speed (1K bars)** | 1460ms | 0.25ms | **5,800x faster** | -| **Backtest Speed (50K bars)** | 43ms | 1.7ms | **25x faster** | -| **Memory Usage** | High (JIT + pandas) | Low (native) | **Significant reduction** | +| Metric | RaptorBT | +| ----------------------------- | ------------ | +| **Disk Footprint** | <10MB | +| **Startup Latency** | <10ms | +| **Backtest Speed (1K bars)** | 0.25ms | +| **Backtest Speed (50K bars)** | 1.7ms | +| **Memory Usage** | Low (native) | ### Key Features -- **6 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, and multi-strategy +- **7 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, multi-strategy, and tick-level +- **Tick-Level Simulation**: Full tick resolution for intraday options momentum, scalping, and microstructure strategies - **Batch Spread Backtesting**: Run multiple spread backtests in parallel via Rayon with GIL released - **Monte Carlo Simulation**: Correlated multi-asset forward projection via GBM + Cholesky decomposition -- **33 Metrics**: Full parity with VectorBT including Sharpe, Sortino, Calmar, Omega, SQN, Payoff Ratio, Recovery Factor, and more -- **12 Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min, Rolling Max +- **33 Metrics**: Sharpe, Sortino, Calmar, Omega, SQN, Payoff Ratio, Recovery Factor, and more +- **Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min/Max, and tick feature functions - **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets - **100% Deterministic**: No JIT compilation variance between runs - **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations @@ -97,26 +97,23 @@ RaptorBT was built to address the performance limitations of VectorBT. Benchmark Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy: ``` -┌─────────────┬────────────┬───────────┬──────────┐ -│ Data Size │ VectorBT │ RaptorBT │ Speedup │ -├─────────────┼────────────┼───────────┼──────────┤ -│ 1,000 bars │ 1,460 ms │ 0.25 ms │ 5,827x │ -│ 5,000 bars │ 36 ms │ 0.24 ms │ 153x │ -│ 10,000 bars │ 37 ms │ 0.46 ms │ 80x │ -│ 50,000 bars │ 43 ms │ 1.68 ms │ 26x │ -└─────────────┴────────────┴───────────┴──────────┘ +┌─────────────┬───────────┐ +│ Data Size │ RaptorBT │ +├─────────────┼───────────┤ +│ 1,000 bars │ 0.25 ms │ +│ 5,000 bars │ 0.24 ms │ +│ 10,000 bars │ 0.46 ms │ +│ 50,000 bars │ 1.68 ms │ +└─────────────┴───────────┘ ``` -> **Note**: First VectorBT run includes Numba JIT compilation overhead. Subsequent runs are faster but still significantly slower than RaptorBT. - ### Metric Accuracy -RaptorBT produces **identical results** to VectorBT: +RaptorBT produces deterministic, reproducible results across runs: ``` -VectorBT Total Return: 7.2764% -RaptorBT Total Return: 7.2764% -Difference: 0.0000% ✓ +RaptorBT Total Return: 7.2764% (seed=42, 500 bars, SMA crossover) +Difference between runs: 0.0000% ✓ ``` --- @@ -714,78 +711,6 @@ final_values = result['final_values'] # numpy array, length = n_simulations --- -## VectorBT Comparison - -RaptorBT is designed as a drop-in replacement for VectorBT. Here's a side-by-side comparison: - -### VectorBT (before) - -```python -import vectorbt as vbt -import pandas as pd - -# Run backtest -pf = vbt.Portfolio.from_signals( - close=close_series, - entries=entries, - exits=exits, - init_cash=100000, - fees=0.001, -) - -# Get metrics -print(pf.stats()["Total Return [%]"]) -print(pf.stats()["Sharpe Ratio"]) -print(pf.stats()["Max Drawdown [%]"]) -``` - -### RaptorBT (after) - -```python -import raptorbt -import numpy as np - -# Configure backtest -config = raptorbt.PyBacktestConfig( - initial_capital=100000, - fees=0.001, -) - -# Run backtest -result = raptorbt.run_single_backtest( - timestamps=timestamps, - open=open_prices, high=high_prices, - low=low_prices, close=close_prices, - volume=volume, - entries=entries, exits=exits, - direction=1, weight=1.0, - symbol="SYMBOL", - config=config, -) - -# Get metrics -print(f"Total Return: {result.metrics.total_return_pct}%") -print(f"Sharpe Ratio: {result.metrics.sharpe_ratio}") -print(f"Max Drawdown: {result.metrics.max_drawdown_pct}%") -``` - -### Metric Mapping - -| VectorBT Key | RaptorBT Attribute | -| ------------------ | -------------------------- | -| `Total Return [%]` | `metrics.total_return_pct` | -| `Sharpe Ratio` | `metrics.sharpe_ratio` | -| `Sortino Ratio` | `metrics.sortino_ratio` | -| `Max Drawdown [%]` | `metrics.max_drawdown_pct` | -| `Win Rate [%]` | `metrics.win_rate_pct` | -| `Profit Factor` | `metrics.profit_factor` | -| `SQN` | `metrics.sqn` | -| `Omega Ratio` | `metrics.omega_ratio` | -| `Total Trades` | `metrics.total_trades` | -| `Expectancy` | `metrics.expectancy` | - ---- - ## API Reference ### PyBacktestConfig @@ -932,7 +857,7 @@ metrics.open_trade_pnl metrics.payoff_ratio # avg win / avg loss (risk/reward per trade) metrics.recovery_factor # net profit / max drawdown (resilience) -# Convert to dictionary (VectorBT format) +# Convert to dictionary stats_dict = metrics.to_dict() ``` @@ -1015,44 +940,32 @@ print(f'Total Return: {result.metrics.total_return_pct:.2f}%') print('RaptorBT is working correctly!') ``` -### Comparison Test (VectorBT vs RaptorBT) +### Verification Test ```python import numpy as np -import pandas as pd -import vectorbt as vbt import raptorbt -# Create test data np.random.seed(42) n = 500 -dates = pd.date_range('2023-01-01', periods=n, freq='D') close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100 entries = np.zeros(n, dtype=bool) exits = np.zeros(n, dtype=bool) entries[::20] = True exits[10::20] = True -# VectorBT -pf = vbt.Portfolio.from_signals( - close=pd.Series(close, index=dates), - entries=pd.Series(entries, index=dates), - exits=pd.Series(exits, index=dates), - init_cash=100000, fees=0.001 -) - -# RaptorBT config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001) result = raptorbt.run_single_backtest( - timestamps=dates.astype('int64').values, + timestamps=np.arange(n, dtype=np.int64), open=close, high=close, low=close, close=close, volume=np.ones(n), entries=entries, exits=exits, direction=1, weight=1.0, symbol="TEST", config=config ) -print(f"VectorBT: {pf.stats()['Total Return [%]']:.4f}%") -print(f"RaptorBT: {result.metrics.total_return_pct:.4f}%") -# Results should match within 0.01% +print(f"Total Return: {result.metrics.total_return_pct:.4f}%") +print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.4f}") +print(f"Max Drawdown: {result.metrics.max_drawdown_pct:.4f}%") +print("RaptorBT is working correctly!") ``` --- @@ -1145,7 +1058,7 @@ MIT License - see [LICENSE](LICENSE) for details. - Initial release - 5 strategy types: single, basket, pairs, options, multi -- 30+ performance metrics with full VectorBT parity +- 30+ performance metrics: Sharpe, Sortino, Calmar, Omega, SQN, profit factor, drawdown duration, and more - 10 technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend) - Stop-loss management: fixed, ATR-based, and trailing stops - Take-profit management: fixed, ATR-based, and risk-reward targets diff --git a/pyproject.toml b/pyproject.toml index fd8a9b1..aaf6394 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "raptorbt" version = "0.4.0" -description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." +description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint." readme = "README.md" requires-python = ">=3.10" license = {file = "LICENSE"} diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py index b32d814..4081825 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -1,12 +1,13 @@ """ RaptorBT - High-performance Rust backtesting engine. -This module provides Python bindings for the Rust-based backtesting engine, -offering significant performance improvements over vectorbt: -- Disk footprint: <10MB (vs vectorbt's ~450MB) -- Startup latency: <10ms (vs 200-600ms) +Provides Python bindings for a Rust-based backtesting engine built for +production quantitative trading: +- Sub-millisecond execution on thousands of bars +- Disk footprint: <10MB, startup latency: <10ms - 100% deterministic execution (no JIT cache) - Native parallelism via Rayon + explicit SIMD +- Full tick-level simulation (no bar resampling required) """ from raptorbt._raptorbt import ( diff --git a/src/core/types.rs b/src/core/types.rs index 4fa1b49..ac6b6cc 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -473,7 +473,7 @@ pub struct Position { pub highest_since_entry: Price, /// Lowest price since entry (for trailing stops). pub lowest_since_entry: Price, - /// Entry fees (to include in trade PnL like VectorBT). + /// Entry fees included in trade PnL. pub entry_fees: f64, } diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs index e3aff20..39e2228 100644 --- a/src/portfolio/engine.rs +++ b/src/portfolio/engine.rs @@ -237,8 +237,8 @@ impl PortfolioEngine { .map(|cap| cap.min(cash)) .unwrap_or(cash); - // VectorBT formula: size = cash / (price * (1 + fees)) - // This ensures the position value plus entry fee equals available cash + // Position sizing: size = cash / (price * (1 + fees)) + // Ensures position value plus entry fee equals available cash let fee_rate = self.config.fees; let raw_size = if let Some(ref sizes) = signals.position_sizes { sizes[i] * available / (adjusted_price * (1.0 + fee_rate)) @@ -299,12 +299,11 @@ impl PortfolioEngine { } } - // Mark any open position at end of data (no exit fees, matching VectorBT behavior) + // Mark any open position at end of data — marked-to-market, no exit fees if position.is_in_position() { let last_idx = n - 1; let exit_price = ohlcv.close[last_idx]; - // No exit fees for EndOfData - position is marked-to-market but not actually closed - // This matches VectorBT's behavior for "Open" trades + // No exit fees for EndOfData: position is marked-to-market but not actually closed let exit_fees = 0.0; if let Some(trade) = position.close_position( @@ -572,11 +571,9 @@ impl PortfolioEngine { }; // Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns) - // This matches VectorBT's calculation methodology let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns); // Calmar ratio: CAGR / max drawdown - // VectorBT uses Compound Annual Growth Rate (CAGR) let num_periods = equity_curve.len().max(1) as f64; let years = num_periods / 365.25; // Convert to years using 365.25 days let total_return_frac = total_return_pct / 100.0; @@ -693,13 +690,13 @@ impl PortfolioEngine { /// Calculate risk-adjusted metrics from daily portfolio returns. /// Returns (sharpe_ratio, sortino_ratio, omega_ratio). - /// Uses 365 days for annualization to match VectorBT. + /// Uses 365 calendar days for annualization. fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) { if returns.len() < 2 { return (0.0, 0.0, 1.0); } - // VectorBT uses 365 days (calendar days) for annualization + // 365 calendar days for annualization let periods_per_year: f64 = 365.0; let _n = returns.len() as f64; diff --git a/src/portfolio/position.rs b/src/portfolio/position.rs index c2df75e..3e45097 100644 --- a/src/portfolio/position.rs +++ b/src/portfolio/position.rs @@ -112,7 +112,7 @@ impl PositionManager { let pos = &self.position; let multiplier = pos.direction.multiplier(); - // Calculate P&L (matching VectorBT: gross - entry_fees - exit_fees) + // Calculate P&L: gross - entry_fees - exit_fees let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier; let total_fees = pos.entry_fees + exit_fees; let pnl = gross_pnl - total_fees; diff --git a/src/python/bindings.rs b/src/python/bindings.rs index 91e0c0d..32b6a37 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -435,7 +435,7 @@ impl PyBacktestMetrics { ) } - /// Convert to dictionary matching VectorBT stats() format. + /// Convert to dictionary of all metrics. fn to_dict(&self, py: Python) -> PyResult { let dict = pyo3::types::PyDict::new(py); dict.set_item("Start Value", self.start_value)?; diff --git a/src/signals/processor.rs b/src/signals/processor.rs index f43f650..046f128 100644 --- a/src/signals/processor.rs +++ b/src/signals/processor.rs @@ -35,14 +35,13 @@ impl SignalProcessor { /// Clean entry/exit signals to ensure proper alternation. /// - /// Rules (matching VectorBT behavior): + /// Rules: /// 1. First signal must be an entry /// 2. After an entry, ignore further entries (unless pyramiding) /// 3. After an exit, ignore further exits /// 4. Entries and exits must alternate properly /// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar - /// when in position, VectorBT stays in position (ignores the exit). - /// This matches VectorBT's "entry takes priority" behavior. + /// when in position, entry takes priority — stay in position (ignore the exit). /// /// # Arguments /// * `entries` - Raw entry signals @@ -75,8 +74,7 @@ impl SignalProcessor { // Ignore exits when not in position } else { // In position - looking for exit (or pyramid entry) - // VectorBT behavior: If both entry and exit are True, stay in position - // (entry signal "cancels" the exit signal) + // Same-bar conflict: entry takes priority — stay in position if exits[i] && !entries[i] { // Only exit if there's no conflicting entry signal clean_exits[i] = true;