From fa6959bb994d2f0f2d30e2d6ce4d72b913b63181 Mon Sep 17 00:00:00 2001 From: porcelaincode Date: Wed, 3 Jun 2026 21:27:54 +0530 Subject: [PATCH] =?UTF-8?q?feat(tick):=20add=20run=5Ftick=5Fbacktest=20?= =?UTF-8?q?=E2=80=94=20tick-native=20simulation=20engine,=20bump=20to=200.?= =?UTF-8?q?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); + } +}