Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 335b3c3b68 | |||
| 83eac3aa80 | |||
| 6d1b99fc05 | |||
| fc0c756203 | |||
| 3a9f7564ad | |||
| fb3a2dda25 | |||
| 3420edee2b | |||
| fa6959bb99 | |||
| 514c235f1c | |||
| 7e91293e1a | |||
| c9451069d8 |
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.3.3"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "raptorbt"
|
||||
version = "0.3.3"
|
||||
version = "0.4.1"
|
||||
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 <contact@alphabench.in>"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/alphabench/raptorbt"
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
**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. It runs single-instrument, basket, pairs, options, spread, multi-strategy, and tick-level backtests over any OHLCV or tick arrays — from any broker, market, or asset class — and returns a full performance report in sub-millisecond time.
|
||||
|
||||
<p align="center">
|
||||
<strong>5,800x faster</strong> · <strong>45x smaller</strong> · <strong>100% deterministic</strong>
|
||||
<strong>Sub-millisecond backtests</strong> · <strong><1 MB compiled engine</strong> · <strong>Bit-for-bit deterministic</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -33,9 +33,18 @@ config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
|
||||
# Run backtest
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=timestamps, open=open, high=high, low=low, close=close,
|
||||
volume=volume, entries=entries, exits=exits,
|
||||
direction=1, weight=1.0, symbol="AAPL", config=config,
|
||||
timestamps=timestamps,
|
||||
open=open,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close,
|
||||
volume=volume,
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
direction=1,
|
||||
weight=1.0,
|
||||
symbol="AAPL",
|
||||
config=config,
|
||||
)
|
||||
|
||||
# Results
|
||||
@@ -43,49 +52,52 @@ print(f"Return: {result.metrics.total_return_pct:.2f}%")
|
||||
print(f"Sharpe: {result.metrics.sharpe_ratio:.2f}")
|
||||
```
|
||||
|
||||
---
|
||||
RaptorBT is open source (MIT) and developed by the [Alphabench](https://alphabench.in) team.
|
||||
|
||||
Developed and maintained by the [Alphabench](https://alphabench.in) team.
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Performance](#performance)
|
||||
- [Architecture](#architecture)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Strategy Types](#strategy-types)
|
||||
- [Metrics](#metrics)
|
||||
- [Indicators](#indicators)
|
||||
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
|
||||
- [VectorBT Comparison](#vectorbt-comparison)
|
||||
- [Monte Carlo Portfolio Simulation](#monte-carlo-portfolio-simulation)
|
||||
- [API Reference](#api-reference)
|
||||
- [Building from Source](#building-from-source)
|
||||
- [Testing](#testing)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RaptorBT was built to address the performance limitations of VectorBT. Benchmarked by the Alphabench team:
|
||||
RaptorBT compiles to a single native extension and runs entirely in Rust, so a
|
||||
full backtest with all 33 metrics finishes in well under a millisecond on
|
||||
typical bar counts. Measured on an Apple M4 (raptorbt 0.4.0):
|
||||
|
||||
| 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 |
|
||||
| ----------------------------- | ------------ |
|
||||
| **Compiled engine size** | <1 MB |
|
||||
| **Backtest speed (1K bars)** | ~0.03 ms |
|
||||
| **Backtest speed (10K bars)** | ~0.25 ms |
|
||||
| **Backtest speed (50K bars)** | ~1.4 ms |
|
||||
| **Memory usage** | Low (native) |
|
||||
|
||||
See [Performance](#performance) for the full method and how to reproduce these
|
||||
numbers on your own hardware.
|
||||
|
||||
### 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
|
||||
- **Asset- and broker-agnostic**: Pass NumPy OHLCV or tick arrays from any source — equities, futures, FX, crypto, options — RaptorBT never assumes a market or data vendor
|
||||
- **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
|
||||
- **20 Indicator & Tick Functions**: 12 classic technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min/Max) plus 8 tick microstructure/feature functions
|
||||
- **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets
|
||||
- **100% Deterministic**: No JIT compilation variance between runs
|
||||
- **Deterministic**: Identical inputs produce bit-for-bit identical results across runs — no JIT compilation variance
|
||||
- **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations
|
||||
|
||||
---
|
||||
@@ -94,202 +106,96 @@ RaptorBT was built to address the performance limitations of VectorBT. Benchmark
|
||||
|
||||
### Benchmark Results
|
||||
|
||||
Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy:
|
||||
Measured on an Apple M4 (raptorbt 0.4.0, Python 3.11) with random-walk price
|
||||
data and an SMA-crossover strategy. Each figure is the fastest of several
|
||||
hundred repetitions of `run_single_backtest` (so it reflects engine time, not
|
||||
scheduler noise):
|
||||
|
||||
```
|
||||
┌─────────────┬────────────┬───────────┬──────────┐
|
||||
│ 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.03 ms │
|
||||
│ 5,000 bars │ 0.13 ms │
|
||||
│ 10,000 bars │ 0.25 ms │
|
||||
│ 50,000 bars │ 1.37 ms │
|
||||
└─────────────┴───────────┘
|
||||
```
|
||||
|
||||
> **Note**: First VectorBT run includes Numba JIT compilation overhead. Subsequent runs are faster but still significantly slower than RaptorBT.
|
||||
Timings scale roughly linearly with bar count and will vary with your CPU,
|
||||
data, and signal density. Reproduce them with the [Verification Test](#verification-test)
|
||||
below, swapping in your own array sizes.
|
||||
|
||||
### Metric Accuracy
|
||||
### Determinism
|
||||
|
||||
RaptorBT produces **identical results** to VectorBT:
|
||||
RaptorBT is fully deterministic: the same inputs produce bit-for-bit identical
|
||||
results across runs (no JIT warmup, no nondeterministic reductions). Running the
|
||||
[Verification Test](#verification-test) five times in a row on this machine
|
||||
produced the same total return every time, to the last decimal:
|
||||
|
||||
```
|
||||
VectorBT Total Return: 7.2764%
|
||||
RaptorBT Total Return: 7.2764%
|
||||
Difference: 0.0000% ✓
|
||||
Total return: -30.6192% (seed=42, 500 bars, periodic entries/exits)
|
||||
Max difference across 5 runs: 0.0000000000%
|
||||
```
|
||||
|
||||
(The exact return depends on your data and signals — the point is that it does
|
||||
not change between runs.)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Strategy Types
|
||||
|
||||
```
|
||||
raptorbt/
|
||||
├── src/
|
||||
│ ├── core/ # Core types and error handling
|
||||
│ │ ├── types.rs # BacktestConfig, BacktestResult, Trade, Metrics
|
||||
│ │ ├── error.rs # RaptorError enum
|
||||
│ │ ├── session.rs # SessionTracker, SessionConfig (intraday sessions)
|
||||
│ │ └── timeseries.rs # Time series utilities
|
||||
│ │
|
||||
│ ├── strategies/ # Strategy implementations
|
||||
│ │ ├── single.rs # Single instrument backtest
|
||||
│ │ ├── basket.rs # Basket/collective strategies
|
||||
│ │ ├── pairs.rs # Pairs trading
|
||||
│ │ ├── options.rs # Options strategies
|
||||
│ │ ├── spreads.rs # Multi-leg spread strategies
|
||||
│ │ └── multi.rs # Multi-strategy combining
|
||||
│ │
|
||||
│ ├── indicators/ # Technical indicators
|
||||
│ │ ├── trend.rs # SMA, EMA, Supertrend
|
||||
│ │ ├── momentum.rs # RSI, MACD, Stochastic
|
||||
│ │ ├── volatility.rs # ATR, Bollinger Bands
|
||||
│ │ ├── strength.rs # ADX
|
||||
│ │ ├── volume.rs # VWAP
|
||||
│ │ └── rolling.rs # Rolling Min/Max (LLV/HHV)
|
||||
│ │
|
||||
│ ├── metrics/ # Performance metrics
|
||||
│ │ ├── streaming.rs # Streaming metric calculations
|
||||
│ │ ├── drawdown.rs # Drawdown analysis
|
||||
│ │ └── trade_stats.rs # Trade statistics
|
||||
│ │
|
||||
│ ├── signals/ # Signal processing
|
||||
│ │ ├── processor.rs # Entry/exit signal processing
|
||||
│ │ ├── synchronizer.rs # Multi-instrument sync
|
||||
│ │ └── expression.rs # Signal expressions
|
||||
│ │
|
||||
│ ├── stops/ # Stop-loss implementations
|
||||
│ │ ├── fixed.rs # Fixed percentage stops
|
||||
│ │ ├── atr.rs # ATR-based stops
|
||||
│ │ └── trailing.rs # Trailing stops
|
||||
│ │
|
||||
│ ├── portfolio/ # Portfolio-level analysis
|
||||
│ │ ├── monte_carlo.rs # Monte Carlo forward simulation (GBM + Cholesky)
|
||||
│ │ ├── allocation.rs # Capital allocation
|
||||
│ │ ├── engine.rs # Portfolio engine
|
||||
│ │ └── position.rs # Position management
|
||||
│ │
|
||||
│ ├── python/ # PyO3 bindings
|
||||
│ │ ├── bindings.rs # Python function exports
|
||||
│ │ └── numpy_bridge.rs # NumPy array conversion
|
||||
│ │
|
||||
│ └── lib.rs # Library entry point
|
||||
│
|
||||
├── Cargo.toml # Rust dependencies
|
||||
└── pyproject.toml # Python package config
|
||||
```
|
||||
All strategy entrypoints take NumPy arrays directly. Signals (`entries` / `exits`)
|
||||
are boolean arrays you compute however you like — pandas, the built-in
|
||||
[indicators](#indicators), or your own model. The engine is asset- and
|
||||
broker-agnostic: timestamps are `int64` (nanoseconds for tick data; any
|
||||
monotonic int for bars), prices are `float64`.
|
||||
|
||||
---
|
||||
### 1. Single Instrument
|
||||
|
||||
## Installation
|
||||
|
||||
### From Pre-built Wheel
|
||||
|
||||
```bash
|
||||
pip install raptorbt
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```python
|
||||
import raptorbt
|
||||
print("RaptorBT installed successfully!")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Single Instrument Backtest
|
||||
Long or short on one instrument. This is the canonical example — the other
|
||||
strategy types follow the same shape.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import raptorbt
|
||||
|
||||
# Prepare data
|
||||
df = pd.read_csv("your_data.csv", index_col=0, parse_dates=True)
|
||||
|
||||
# Generate signals (SMA crossover example)
|
||||
sma_fast = df['close'].rolling(10).mean()
|
||||
sma_slow = df['close'].rolling(20).mean()
|
||||
# Signals (SMA crossover) — any boolean arrays work here
|
||||
sma_fast = df["close"].rolling(10).mean()
|
||||
sma_slow = df["close"].rolling(20).mean()
|
||||
entries = (sma_fast > sma_slow) & (sma_fast.shift(1) <= sma_slow.shift(1))
|
||||
exits = (sma_fast < sma_slow) & (sma_fast.shift(1) >= sma_slow.shift(1))
|
||||
|
||||
# Configure backtest
|
||||
config = raptorbt.PyBacktestConfig(
|
||||
initial_capital=100000,
|
||||
fees=0.001, # 0.1% per trade
|
||||
slippage=0.0005, # 0.05% slippage
|
||||
upon_bar_close=True
|
||||
)
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001, slippage=0.0005)
|
||||
config.set_fixed_stop(0.02) # optional 2% stop-loss
|
||||
config.set_fixed_target(0.04) # optional 4% take-profit
|
||||
|
||||
# Optional: Add stop-loss
|
||||
config.set_fixed_stop(0.02) # 2% stop-loss
|
||||
|
||||
# Optional: Add take-profit
|
||||
config.set_fixed_target(0.04) # 4% take-profit
|
||||
|
||||
# Run backtest
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=df.index.astype('int64').values,
|
||||
open=df['open'].values,
|
||||
high=df['high'].values,
|
||||
low=df['low'].values,
|
||||
close=df['close'].values,
|
||||
volume=df['volume'].values,
|
||||
timestamps=df.index.astype("int64").values,
|
||||
open=df["open"].values,
|
||||
high=df["high"].values,
|
||||
low=df["low"].values,
|
||||
close=df["close"].values,
|
||||
volume=df["volume"].values,
|
||||
entries=entries.values,
|
||||
exits=exits.values,
|
||||
direction=1, # 1 = Long, -1 = Short
|
||||
direction=1, # 1 = long, -1 = short
|
||||
weight=1.0,
|
||||
symbol="AAPL",
|
||||
config=config,
|
||||
instrument_config=raptorbt.PyInstrumentConfig(lot_size=1.0), # optional: lot rounding, capital cap
|
||||
)
|
||||
|
||||
# Access results
|
||||
print(f"Total Return: {result.metrics.total_return_pct:.2f}%")
|
||||
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.2f}")
|
||||
print(f"Max Drawdown: {result.metrics.max_drawdown_pct:.2f}%")
|
||||
print(f"Win Rate: {result.metrics.win_rate_pct:.2f}%")
|
||||
print(f"Total Trades: {result.metrics.total_trades}")
|
||||
print(f"Return {result.metrics.total_return_pct:.2f}% "
|
||||
f"Sharpe {result.metrics.sharpe_ratio:.2f} "
|
||||
f"MaxDD {result.metrics.max_drawdown_pct:.2f}% "
|
||||
f"Trades {result.metrics.total_trades}")
|
||||
|
||||
# Get equity curve
|
||||
equity = result.equity_curve() # Returns numpy array
|
||||
|
||||
# Get trades
|
||||
trades = result.trades() # Returns list of PyTrade objects
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy Types
|
||||
|
||||
### 1. Single Instrument
|
||||
|
||||
Basic long or short strategy on a single instrument.
|
||||
|
||||
```python
|
||||
# Optional: Instrument-specific configuration
|
||||
inst_config = raptorbt.PyInstrumentConfig(lot_size=1.0)
|
||||
|
||||
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, # 1=Long, -1=Short
|
||||
weight=1.0,
|
||||
symbol="SYMBOL",
|
||||
config=config,
|
||||
instrument_config=inst_config, # Optional: lot_size rounding, capital caps
|
||||
)
|
||||
equity = result.equity_curve() # np.ndarray
|
||||
trades = result.trades() # list[PyTrade]
|
||||
```
|
||||
|
||||
### 2. Basket/Collective
|
||||
@@ -333,16 +239,21 @@ Long one instrument, short another with optional hedge ratio.
|
||||
result = raptorbt.run_pairs_backtest(
|
||||
# Long leg
|
||||
leg1_timestamps=timestamps,
|
||||
leg1_open=long_open, leg1_high=long_high,
|
||||
leg1_low=long_low, leg1_close=long_close,
|
||||
leg1_open=long_open,
|
||||
leg1_high=long_high,
|
||||
leg1_low=long_low,
|
||||
leg1_close=long_close,
|
||||
leg1_volume=long_volume,
|
||||
# Short leg
|
||||
leg2_timestamps=timestamps,
|
||||
leg2_open=short_open, leg2_high=short_high,
|
||||
leg2_low=short_low, leg2_close=short_close,
|
||||
leg2_open=short_open,
|
||||
leg2_high=short_high,
|
||||
leg2_low=short_low,
|
||||
leg2_close=short_close,
|
||||
leg2_volume=short_volume,
|
||||
# Signals
|
||||
entries=entries, exits=exits,
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
direction=1,
|
||||
symbol="TCS_INFY",
|
||||
config=config,
|
||||
@@ -358,11 +269,14 @@ Backtest options strategies with strike selection.
|
||||
```python
|
||||
result = raptorbt.run_options_backtest(
|
||||
timestamps=timestamps,
|
||||
open=underlying_open, high=underlying_high,
|
||||
low=underlying_low, close=underlying_close,
|
||||
open=underlying_open,
|
||||
high=underlying_high,
|
||||
low=underlying_low,
|
||||
close=underlying_close,
|
||||
volume=volume,
|
||||
option_prices=option_prices, # Option premium series
|
||||
entries=entries, exits=exits,
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
direction=1,
|
||||
symbol="NIFTY_CE",
|
||||
config=config,
|
||||
@@ -388,8 +302,10 @@ strategies = [
|
||||
|
||||
result = raptorbt.run_multi_backtest(
|
||||
timestamps=timestamps,
|
||||
open=open_prices, high=high_prices,
|
||||
low=low_prices, close=close_prices,
|
||||
open=open_prices,
|
||||
high=high_prices,
|
||||
low=low_prices,
|
||||
close=close_prices,
|
||||
volume=volume,
|
||||
strategies=strategies,
|
||||
config=config,
|
||||
@@ -449,11 +365,83 @@ 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<f64> 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
|
||||
|
||||
RaptorBT calculates 30+ performance metrics:
|
||||
Every backtest returns a `PyBacktestMetrics` object exposing **33 metric fields**
|
||||
(listed in full under [PyBacktestMetrics](#pybacktestmetrics)). `metrics.to_dict()`
|
||||
returns a subset of 24 of them under human-readable labels (e.g. `"Sharpe Ratio"`,
|
||||
`"Total Return [%]"`) for quick display; read fields directly off the object to
|
||||
access all 33. The most useful are grouped below.
|
||||
|
||||
### Core Performance
|
||||
|
||||
@@ -525,7 +513,8 @@ RaptorBT calculates 30+ performance metrics:
|
||||
|
||||
## Indicators
|
||||
|
||||
RaptorBT includes optimized technical indicators:
|
||||
RaptorBT exports **12 classic technical indicators**, computed in native Rust
|
||||
and operating on (and returning) NumPy arrays:
|
||||
|
||||
```python
|
||||
import raptorbt
|
||||
@@ -537,7 +526,7 @@ supertrend, direction = raptorbt.supertrend(high, low, close, period=10, multipl
|
||||
|
||||
# Momentum indicators
|
||||
rsi = raptorbt.rsi(close, period=14)
|
||||
macd_line, signal_line, histogram = raptorbt.macd(close, fast=12, slow=26, signal=9)
|
||||
macd_line, signal_line, histogram = raptorbt.macd(close, 12, 26, 9) # fast, slow, signal (positional)
|
||||
stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3)
|
||||
|
||||
# Volatility indicators
|
||||
@@ -549,8 +538,18 @@ adx = raptorbt.adx(high, low, close, period=14)
|
||||
|
||||
# Volume indicators
|
||||
vwap = raptorbt.vwap(high, low, close, volume)
|
||||
|
||||
# Rolling indicators (LLV / HHV)
|
||||
rolling_low = raptorbt.rolling_min(low, period=20) # Lowest Low Value
|
||||
rolling_high = raptorbt.rolling_max(high, period=20) # Highest High Value
|
||||
```
|
||||
|
||||
In addition, **8 tick microstructure / feature functions** are available for
|
||||
tick-level work (`tick_spread_pct`, `buy_sell_imbalance_delta`, `return_window`,
|
||||
`realized_vol_rolling`, `oi_position_pct`, `tick_velocity`,
|
||||
`compute_tick_entry_signals`, `compute_tick_exit_signals`) — see
|
||||
[Tick-Level Backtest](#7-tick-level-backtest).
|
||||
|
||||
---
|
||||
|
||||
## Stop-Loss & Take-Profit
|
||||
@@ -646,78 +645,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
|
||||
@@ -826,46 +753,15 @@ result.trades() # List[PyTrade]
|
||||
|
||||
### PyBacktestMetrics
|
||||
|
||||
33 read-only fields — see the [Metrics](#metrics) section for the full table with
|
||||
descriptions. `metrics.to_dict()` returns 24 of them under human-readable labels
|
||||
(e.g. `"Sharpe Ratio"`) for quick display; read fields off the object directly
|
||||
for the complete set.
|
||||
|
||||
```python
|
||||
metrics = result.metrics
|
||||
|
||||
# All available metrics
|
||||
metrics.total_return_pct
|
||||
metrics.sharpe_ratio
|
||||
metrics.sortino_ratio
|
||||
metrics.calmar_ratio
|
||||
metrics.omega_ratio
|
||||
metrics.max_drawdown_pct
|
||||
metrics.max_drawdown_duration
|
||||
metrics.win_rate_pct
|
||||
metrics.profit_factor
|
||||
metrics.expectancy
|
||||
metrics.sqn
|
||||
metrics.total_trades
|
||||
metrics.total_closed_trades
|
||||
metrics.total_open_trades
|
||||
metrics.winning_trades
|
||||
metrics.losing_trades
|
||||
metrics.start_value
|
||||
metrics.end_value
|
||||
metrics.total_fees_paid
|
||||
metrics.best_trade_pct
|
||||
metrics.worst_trade_pct
|
||||
metrics.avg_trade_return_pct
|
||||
metrics.avg_win_pct
|
||||
metrics.avg_loss_pct
|
||||
metrics.avg_holding_period
|
||||
metrics.avg_winning_duration
|
||||
metrics.avg_losing_duration
|
||||
metrics.max_consecutive_wins
|
||||
metrics.max_consecutive_losses
|
||||
metrics.exposure_pct
|
||||
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)
|
||||
stats_dict = metrics.to_dict()
|
||||
m = result.metrics
|
||||
m.total_return_pct, m.sharpe_ratio, m.max_drawdown_pct # etc. — 33 fields total
|
||||
stats = m.to_dict()
|
||||
```
|
||||
|
||||
### PyTrade
|
||||
@@ -883,108 +779,54 @@ for trade in result.trades():
|
||||
print(trade.pnl) # Profit/Loss
|
||||
print(trade.return_pct) # Return percentage
|
||||
print(trade.fees) # Fees paid
|
||||
print(trade.exit_reason) # "Signal", "StopLoss", "TakeProfit"
|
||||
print(trade.exit_reason) # "Signal", "StopLoss", "TakeProfit", "TrailingStop", "EndOfData", "Settlement", "TimeExit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.70+ (install via [rustup](https://rustup.rs/))
|
||||
- Python 3.10+
|
||||
- maturin (`pip install maturin`)
|
||||
|
||||
### Development Build
|
||||
Most users should `pip install raptorbt`. To build the engine yourself you need
|
||||
Rust 1.70+, Python 3.10+, and `maturin`:
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin develop --release
|
||||
maturin develop --release # editable install into the active venv
|
||||
cargo test # run the Rust test suite
|
||||
```
|
||||
|
||||
### Production Build
|
||||
### Verification Test
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
maturin build --release
|
||||
pip install target/wheels/raptorbt-*.whl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Rust Unit Tests
|
||||
|
||||
```bash
|
||||
cd raptorbt
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Python Integration Tests
|
||||
|
||||
```python
|
||||
import raptorbt
|
||||
import numpy as np
|
||||
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=np.arange(100, dtype=np.int64),
|
||||
open=np.random.randn(100).cumsum() + 100,
|
||||
high=np.random.randn(100).cumsum() + 101,
|
||||
low=np.random.randn(100).cumsum() + 99,
|
||||
close=np.random.randn(100).cumsum() + 100,
|
||||
volume=np.ones(100),
|
||||
entries=np.array([i % 20 == 0 for i in range(100)]),
|
||||
exits=np.array([i % 20 == 10 for i in range(100)]),
|
||||
direction=1,
|
||||
weight=1.0,
|
||||
symbol='TEST',
|
||||
config=config,
|
||||
)
|
||||
print(f'Total Return: {result.metrics.total_return_pct:.2f}%')
|
||||
print('RaptorBT is working correctly!')
|
||||
```
|
||||
|
||||
### Comparison Test (VectorBT vs RaptorBT)
|
||||
A seeded smoke test — run it twice and the result is identical to the last
|
||||
decimal (the determinism guarantee):
|
||||
|
||||
```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
|
||||
entries = np.zeros(n, dtype=bool); entries[::20] = True
|
||||
exits = np.zeros(n, dtype=bool); 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,
|
||||
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
|
||||
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}%") # -30.6192%
|
||||
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.4f}") # -0.9086
|
||||
```
|
||||
|
||||
---
|
||||
@@ -997,6 +839,31 @@ 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` / `PyBacktestMetrics` (33 fields) 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
|
||||
- Add `ExitReason::Settlement` for option expiry settlement exits
|
||||
- Add `leg_expiry_timestamps` parameter to `run_spread_backtest` for per-leg expiry tracking
|
||||
- Positions are force-closed at settlement when any leg expires, with premiums replaced by intrinsic value
|
||||
- Prevent re-entry after all legs have expired
|
||||
|
||||
### v0.3.3
|
||||
|
||||
- Add `batch_spread_backtest` function for running multiple spread backtests in parallel via Rayon
|
||||
@@ -1052,7 +919,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
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "raptorbt"
|
||||
version = "0.3.3"
|
||||
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
|
||||
version = "0.4.1"
|
||||
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"}
|
||||
|
||||
@@ -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 (
|
||||
@@ -26,11 +27,22 @@ from raptorbt._raptorbt import (
|
||||
run_pairs_backtest,
|
||||
run_multi_backtest,
|
||||
run_spread_backtest,
|
||||
run_tick_backtest,
|
||||
# Batch backtest
|
||||
PyBatchSpreadItem,
|
||||
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,
|
||||
@@ -46,7 +58,7 @@ from raptorbt._raptorbt import (
|
||||
rolling_max,
|
||||
)
|
||||
|
||||
__version__ = "0.3.3"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
__all__ = [
|
||||
# Config classes
|
||||
@@ -65,11 +77,22 @@ __all__ = [
|
||||
"run_pairs_backtest",
|
||||
"run_multi_backtest",
|
||||
"run_spread_backtest",
|
||||
"run_tick_backtest",
|
||||
# Batch backtest
|
||||
"PyBatchSpreadItem",
|
||||
"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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+43
-1
@@ -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<Timestamp>,
|
||||
/// Last traded price at each tick.
|
||||
pub ltp: Vec<Price>,
|
||||
/// Best bid price at each tick (0.0 if unavailable).
|
||||
pub bid: Vec<Price>,
|
||||
/// Best ask price at each tick (0.0 if unavailable).
|
||||
pub ask: Vec<Price>,
|
||||
/// Per-tick buy quantity delta (not cumulative).
|
||||
pub buy_qty_delta: Vec<f64>,
|
||||
/// Per-tick sell quantity delta (not cumulative).
|
||||
pub sell_qty_delta: Vec<f64>,
|
||||
/// Open interest at each tick (0 if unavailable).
|
||||
pub oi: Vec<f64>,
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -212,6 +250,10 @@ pub enum ExitReason {
|
||||
TrailingStop,
|
||||
/// End of data.
|
||||
EndOfData,
|
||||
/// Option expiry settlement.
|
||||
Settlement,
|
||||
/// Max hold time exceeded (tick backtest).
|
||||
TimeExit,
|
||||
}
|
||||
|
||||
/// Backtest configuration.
|
||||
@@ -431,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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::<python::bindings::PyBatchSpreadItem>()?;
|
||||
@@ -51,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)?)?;
|
||||
|
||||
+25
-9
@@ -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;
|
||||
|
||||
@@ -761,6 +758,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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+285
-2
@@ -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::*;
|
||||
|
||||
@@ -434,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<PyObject> {
|
||||
let dict = pyo3::types::PyDict::new(py);
|
||||
dict.set_item("Start Value", self.start_value)?;
|
||||
@@ -782,7 +783,7 @@ pub fn run_pairs_backtest<'py>(
|
||||
|
||||
/// Run spread backtest (multi-leg options).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None))]
|
||||
#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None, leg_expiry_timestamps=None))]
|
||||
pub fn run_spread_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
@@ -795,6 +796,7 @@ pub fn run_spread_backtest<'py>(
|
||||
spread_type: &str,
|
||||
max_loss: Option<f64>,
|
||||
target_profit: Option<f64>,
|
||||
leg_expiry_timestamps: Option<Vec<i64>>,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ts = numpy_to_vec_i64(timestamps);
|
||||
let underlying = numpy_to_vec_f64(underlying_close);
|
||||
@@ -824,6 +826,10 @@ pub fn run_spread_backtest<'py>(
|
||||
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||
"calendar" => SpreadType::Calendar,
|
||||
"diagonal" => SpreadType::Diagonal,
|
||||
"long_call" | "longcall" => SpreadType::LongCall,
|
||||
"long_put" | "longput" => SpreadType::LongPut,
|
||||
"naked_call" | "nakedcall" => SpreadType::NakedCall,
|
||||
"naked_put" | "nakedput" => SpreadType::NakedPut,
|
||||
_ => SpreadType::Custom,
|
||||
};
|
||||
|
||||
@@ -834,6 +840,7 @@ pub fn run_spread_backtest<'py>(
|
||||
max_loss,
|
||||
target_profit,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps,
|
||||
};
|
||||
|
||||
let backtest = SpreadBacktest::new(spread_config);
|
||||
@@ -943,6 +950,10 @@ pub fn batch_spread_backtest(
|
||||
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||
"calendar" => SpreadType::Calendar,
|
||||
"diagonal" => SpreadType::Diagonal,
|
||||
"long_call" | "longcall" => SpreadType::LongCall,
|
||||
"long_put" | "longput" => SpreadType::LongPut,
|
||||
"naked_call" | "nakedcall" => SpreadType::NakedCall,
|
||||
"naked_put" | "nakedput" => SpreadType::NakedPut,
|
||||
_ => SpreadType::Custom,
|
||||
};
|
||||
|
||||
@@ -953,6 +964,7 @@ pub fn batch_spread_backtest(
|
||||
max_loss: item.max_loss,
|
||||
target_profit: item.target_profit,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps: None,
|
||||
};
|
||||
|
||||
PreparedItem {
|
||||
@@ -1038,6 +1050,277 @@ 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<i64>,
|
||||
ltp: PyReadonlyArray1<f64>,
|
||||
bid: PyReadonlyArray1<f64>,
|
||||
ask: PyReadonlyArray1<f64>,
|
||||
buy_qty_delta: PyReadonlyArray1<f64>,
|
||||
sell_qty_delta: PyReadonlyArray1<f64>,
|
||||
oi: PyReadonlyArray1<f64>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
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<PyBacktestResult> {
|
||||
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))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<f64>,
|
||||
bsi_delta: PyReadonlyArray1<f64>,
|
||||
return_1m: PyReadonlyArray1<f64>,
|
||||
spread_pct_max: f64,
|
||||
bsi_min: f64,
|
||||
return_1m_min_abs: f64,
|
||||
return_direction: i8,
|
||||
cooldown_ticks: usize,
|
||||
) -> PyResult<&'py PyArray1<bool>> {
|
||||
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<i64>,
|
||||
eod_exit_time_ns: i64,
|
||||
) -> PyResult<&'py PyArray1<bool>> {
|
||||
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<f64>,
|
||||
ask: PyReadonlyArray1<f64>,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<f64>,
|
||||
sell_qty_cumulative: PyReadonlyArray1<f64>,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<i64>,
|
||||
ltp: PyReadonlyArray1<f64>,
|
||||
window_seconds: f64,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<i64>,
|
||||
ltp: PyReadonlyArray1<f64>,
|
||||
window_seconds: f64,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<f64>,
|
||||
oi_day_high: f64,
|
||||
oi_day_low: f64,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<i64>,
|
||||
window_seconds: f64,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
Ok(vec_to_numpy_f64(
|
||||
py,
|
||||
crate::indicators::tick_features::tick_velocity(
|
||||
&numpy_to_vec_i64(timestamps_ns),
|
||||
window_seconds,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Indicator Functions
|
||||
// ============================================================================
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool> {
|
||||
let n = spread_pct.len();
|
||||
let mut entries = vec![false; n];
|
||||
let mut cooldown_until: usize = 0;
|
||||
|
||||
for i in 0..n {
|
||||
if i < cooldown_until {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spread gate
|
||||
if spread_pct[i] > spread_pct_max {
|
||||
continue;
|
||||
}
|
||||
|
||||
// BSI delta gate (disabled when bsi_min == 0.0)
|
||||
if bsi_min > 0.0 {
|
||||
let b = if i < bsi_delta.len() { bsi_delta[i] } else { continue };
|
||||
if b < bsi_min {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 1-minute return gate (disabled when return_1m_min_abs == 0.0)
|
||||
if return_1m_min_abs > 0.0 {
|
||||
let r = if i < return_1m.len() { return_1m[i] } else { continue };
|
||||
if r.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let abs_r = r.abs();
|
||||
if abs_r < return_1m_min_abs {
|
||||
continue;
|
||||
}
|
||||
// Direction alignment: long needs positive return, short needs negative
|
||||
if return_direction > 0 && r < 0.0 {
|
||||
continue;
|
||||
}
|
||||
if return_direction < 0 && r > 0.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
entries[i] = true;
|
||||
cooldown_until = i + 1 + cooldown_ticks;
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Generate time-based exit signals (EOD / session-end).
|
||||
///
|
||||
/// Sets exit[i] = true for every tick at or after `eod_exit_time_ns`.
|
||||
/// When `eod_exit_time_ns == 0` all exits are false (disabled).
|
||||
///
|
||||
/// `timestamps_ns`: nanoseconds-since-epoch timestamp for each tick.
|
||||
pub fn tick_momentum_exit(timestamps_ns: &[i64], eod_exit_time_ns: i64) -> Vec<bool> {
|
||||
let n = timestamps_ns.len();
|
||||
if eod_exit_time_ns == 0 {
|
||||
return vec![false; n];
|
||||
}
|
||||
timestamps_ns
|
||||
.iter()
|
||||
.map(|&ts| ts >= eod_exit_time_ns)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_return_1m(vals: &[f64]) -> Vec<f64> {
|
||||
vals.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_spread_gate() {
|
||||
// All spreads above max → no entries
|
||||
let spread = vec![3.0, 4.0, 6.0];
|
||||
let bsi = vec![0.6, 0.7, 0.8];
|
||||
let ret = vec![1.0, 1.0, 1.0];
|
||||
let entries = tick_momentum_entry(&spread, &bsi, &ret, 2.0, 0.0, 0.0, 1, 0);
|
||||
assert_eq!(entries, vec![false, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_bsi_gate() {
|
||||
let spread = vec![1.0, 1.0, 1.0];
|
||||
let bsi = vec![0.3, 0.6, 0.4]; // only index 1 passes bsi_min=0.5
|
||||
let ret = vec![0.5, 0.5, 0.5];
|
||||
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.5, 0.0, 1, 0);
|
||||
assert_eq!(entries, vec![false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_return_gate_long() {
|
||||
let spread = vec![1.0, 1.0, 1.0, 1.0];
|
||||
let bsi = vec![0.6, 0.6, 0.6, 0.6];
|
||||
// positive, positive, too small, negative
|
||||
let ret = vec![0.5, 1.0, 0.1, -0.5];
|
||||
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, 1, 0);
|
||||
assert_eq!(entries, vec![true, true, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_return_gate_short() {
|
||||
let spread = vec![1.0, 1.0, 1.0];
|
||||
let bsi = vec![0.6, 0.6, 0.6];
|
||||
// negative enough, positive (fails direction), nan
|
||||
let ret = vec![-0.5, 0.5, f64::NAN];
|
||||
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, -1, 0);
|
||||
assert_eq!(entries, vec![true, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_cooldown() {
|
||||
// cooldown_ticks=2: after entry at i=0, next eligible at i=3
|
||||
let spread = vec![1.0; 6];
|
||||
let bsi = vec![0.6; 6];
|
||||
let ret = vec![0.0; 6];
|
||||
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.0, 1, 2);
|
||||
assert!(entries[0]);
|
||||
assert!(!entries[1]);
|
||||
assert!(!entries[2]);
|
||||
assert!(entries[3]);
|
||||
assert!(!entries[4]);
|
||||
assert!(!entries[5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_disabled() {
|
||||
let ts = vec![1_000_000_i64, 2_000_000, 3_000_000];
|
||||
let exits = tick_momentum_exit(&ts, 0);
|
||||
assert_eq!(exits, vec![false, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_eod_fires() {
|
||||
let ts = vec![1_000_i64, 2_000, 3_000, 4_000];
|
||||
let exits = tick_momentum_exit(&ts, 3_000);
|
||||
assert_eq!(exits, vec![false, false, true, true]);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -31,6 +31,10 @@ pub enum SpreadType {
|
||||
ButterflyPut,
|
||||
Calendar,
|
||||
Diagonal,
|
||||
LongCall,
|
||||
LongPut,
|
||||
NakedCall,
|
||||
NakedPut,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -95,6 +99,9 @@ pub struct SpreadConfig {
|
||||
pub target_profit: Option<f64>,
|
||||
/// Whether to close at end of day.
|
||||
pub close_at_eod: bool,
|
||||
/// Per-leg expiry timestamps in nanoseconds (optional, for settlement logic).
|
||||
/// When provided, positions are force-closed at or after the earliest leg expiry.
|
||||
pub leg_expiry_timestamps: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
impl Default for SpreadConfig {
|
||||
@@ -106,6 +113,7 @@ impl Default for SpreadConfig {
|
||||
max_loss: None,
|
||||
target_profit: None,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,9 +259,16 @@ impl SpreadBacktest {
|
||||
// Calculate unrealized P&L for exit checks
|
||||
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
|
||||
|
||||
// Check if any leg has expired at this bar
|
||||
let is_expiry = position.is_some()
|
||||
&& self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
|
||||
expiries.iter().any(|&exp_ts| timestamps[i] >= exp_ts)
|
||||
});
|
||||
|
||||
// Check for exit signals or conditions
|
||||
let should_exit = position.is_some()
|
||||
&& (exits[i]
|
||||
|| is_expiry
|
||||
|| self.check_max_loss(&position, unrealized_pnl)
|
||||
|| self.check_target_profit(&position, unrealized_pnl));
|
||||
|
||||
@@ -267,7 +282,9 @@ impl SpreadBacktest {
|
||||
|
||||
// Record trade
|
||||
trade_id += 1;
|
||||
let exit_reason = if exits[i] {
|
||||
let exit_reason = if is_expiry {
|
||||
ExitReason::Settlement
|
||||
} else if exits[i] {
|
||||
ExitReason::Signal
|
||||
} else if self.check_max_loss(&Some(pos.clone()), pnl) {
|
||||
ExitReason::StopLoss
|
||||
@@ -311,8 +328,12 @@ impl SpreadBacktest {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry signals
|
||||
if position.is_none() && entries[i] {
|
||||
// Check for entry signals (don't re-enter after all legs expired)
|
||||
let all_expired =
|
||||
self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
|
||||
expiries.iter().all(|&exp_ts| timestamps[i] >= exp_ts)
|
||||
});
|
||||
if position.is_none() && entries[i] && !all_expired {
|
||||
let legs: Vec<LegPosition> = self
|
||||
.config
|
||||
.leg_configs
|
||||
|
||||
@@ -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<Trade> = 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<Trade>, 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<f64> = 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<f64> = (0..n).map(|i| base_price + i as f64 * trend).collect();
|
||||
let bid: Vec<f64> = ltp.iter().map(|p| p - 0.5).collect();
|
||||
let ask: Vec<f64> = 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<bool> = (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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user