Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0c756203 | |||
| 3a9f7564ad | |||
| fb3a2dda25 | |||
| 3420edee2b | |||
| fa6959bb99 | |||
| 514c235f1c | |||
| 7e91293e1a | |||
| c9451069d8 | |||
| 997e234a85 | |||
| e049a2f968 | |||
| f7592d5d79 | |||
| 87545683eb | |||
| eb5335e809 | |||
| 0ff67e7fe2 |
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "raptorbt"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
|
||||
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
|
||||
authors = ["Alphabench <contact@alphabench.in>"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/alphabench/raptorbt"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**Blazing-fast backtesting for the modern quant.**
|
||||
|
||||
RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It serves as a drop-in replacement for VectorBT — delivering **HFT-grade compute efficiency** with full metric parity.
|
||||
RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. Built for production quantitative trading — delivering **HFT-grade compute efficiency** with full tick-to-bar coverage.
|
||||
|
||||
<p align="center">
|
||||
<strong>5,800x faster</strong> · <strong>45x smaller</strong> · <strong>100% deterministic</strong>
|
||||
@@ -58,7 +58,6 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team.
|
||||
- [Metrics](#metrics)
|
||||
- [Indicators](#indicators)
|
||||
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
|
||||
- [VectorBT Comparison](#vectorbt-comparison)
|
||||
- [API Reference](#api-reference)
|
||||
- [Building from Source](#building-from-source)
|
||||
- [Testing](#testing)
|
||||
@@ -67,21 +66,24 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team.
|
||||
|
||||
## Overview
|
||||
|
||||
RaptorBT was built to address the performance limitations of VectorBT. Benchmarked by the Alphabench team:
|
||||
RaptorBT is benchmarked by the Alphabench team on Apple Silicon M-series:
|
||||
|
||||
| Metric | VectorBT | RaptorBT | Improvement |
|
||||
| ----------------------------- | ------------------- | ------------ | ------------------------- |
|
||||
| **Disk Footprint** | ~450MB | <10MB | **45x smaller** |
|
||||
| **Startup Latency** | 200-600ms | <10ms | **20-60x faster** |
|
||||
| **Backtest Speed (1K bars)** | 1460ms | 0.25ms | **5,800x faster** |
|
||||
| **Backtest Speed (50K bars)** | 43ms | 1.7ms | **25x faster** |
|
||||
| **Memory Usage** | High (JIT + pandas) | Low (native) | **Significant reduction** |
|
||||
| Metric | RaptorBT |
|
||||
| ----------------------------- | ------------ |
|
||||
| **Disk Footprint** | <10MB |
|
||||
| **Startup Latency** | <10ms |
|
||||
| **Backtest Speed (1K bars)** | 0.25ms |
|
||||
| **Backtest Speed (50K bars)** | 1.7ms |
|
||||
| **Memory Usage** | Low (native) |
|
||||
|
||||
### Key Features
|
||||
|
||||
- **5 Strategy Types**: Single instrument, basket/collective, pairs trading, options, and multi-strategy
|
||||
- **30+ Metrics**: Full parity with VectorBT including Sharpe, Sortino, Calmar, Omega, SQN, and more
|
||||
- **10 Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend
|
||||
- **7 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, multi-strategy, and tick-level
|
||||
- **Tick-Level Simulation**: Full tick resolution for intraday options momentum, scalping, and microstructure strategies
|
||||
- **Batch Spread Backtesting**: Run multiple spread backtests in parallel via Rayon with GIL released
|
||||
- **Monte Carlo Simulation**: Correlated multi-asset forward projection via GBM + Cholesky decomposition
|
||||
- **33 Metrics**: Sharpe, Sortino, Calmar, Omega, SQN, Payoff Ratio, Recovery Factor, and more
|
||||
- **Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min/Max, and tick feature functions
|
||||
- **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets
|
||||
- **100% Deterministic**: No JIT compilation variance between runs
|
||||
- **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations
|
||||
@@ -95,26 +97,23 @@ RaptorBT was built to address the performance limitations of VectorBT. Benchmark
|
||||
Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy:
|
||||
|
||||
```
|
||||
┌─────────────┬────────────┬───────────┬──────────┐
|
||||
│ Data Size │ VectorBT │ RaptorBT │ Speedup │
|
||||
├─────────────┼────────────┼───────────┼──────────┤
|
||||
│ 1,000 bars │ 1,460 ms │ 0.25 ms │ 5,827x │
|
||||
│ 5,000 bars │ 36 ms │ 0.24 ms │ 153x │
|
||||
│ 10,000 bars │ 37 ms │ 0.46 ms │ 80x │
|
||||
│ 50,000 bars │ 43 ms │ 1.68 ms │ 26x │
|
||||
└─────────────┴────────────┴───────────┴──────────┘
|
||||
┌─────────────┬───────────┐
|
||||
│ Data Size │ RaptorBT │
|
||||
├─────────────┼───────────┤
|
||||
│ 1,000 bars │ 0.25 ms │
|
||||
│ 5,000 bars │ 0.24 ms │
|
||||
│ 10,000 bars │ 0.46 ms │
|
||||
│ 50,000 bars │ 1.68 ms │
|
||||
└─────────────┴───────────┘
|
||||
```
|
||||
|
||||
> **Note**: First VectorBT run includes Numba JIT compilation overhead. Subsequent runs are faster but still significantly slower than RaptorBT.
|
||||
|
||||
### Metric Accuracy
|
||||
|
||||
RaptorBT produces **identical results** to VectorBT:
|
||||
RaptorBT produces deterministic, reproducible results across runs:
|
||||
|
||||
```
|
||||
VectorBT Total Return: 7.2764%
|
||||
RaptorBT Total Return: 7.2764%
|
||||
Difference: 0.0000% ✓
|
||||
RaptorBT Total Return: 7.2764% (seed=42, 500 bars, SMA crossover)
|
||||
Difference between runs: 0.0000% ✓
|
||||
```
|
||||
|
||||
---
|
||||
@@ -127,6 +126,7 @@ raptorbt/
|
||||
│ ├── 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
|
||||
@@ -134,6 +134,7 @@ raptorbt/
|
||||
│ │ ├── 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
|
||||
@@ -141,7 +142,8 @@ raptorbt/
|
||||
│ │ ├── momentum.rs # RSI, MACD, Stochastic
|
||||
│ │ ├── volatility.rs # ATR, Bollinger Bands
|
||||
│ │ ├── strength.rs # ADX
|
||||
│ │ └── volume.rs # VWAP
|
||||
│ │ ├── volume.rs # VWAP
|
||||
│ │ └── rolling.rs # Rolling Min/Max (LLV/HHV)
|
||||
│ │
|
||||
│ ├── metrics/ # Performance metrics
|
||||
│ │ ├── streaming.rs # Streaming metric calculations
|
||||
@@ -158,6 +160,12 @@ raptorbt/
|
||||
│ │ ├── 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
|
||||
@@ -394,6 +402,118 @@ result = raptorbt.run_multi_backtest(
|
||||
- `weighted`: Weight signals by strategy weight
|
||||
- `independent`: Run strategies independently (aggregate PnL)
|
||||
|
||||
### 6. Batch Spread Backtest
|
||||
|
||||
Run multiple spread backtests in parallel. Shared data (timestamps, underlying close) is converted once, then each item is backtested on its own Rayon thread with the GIL released for maximum throughput.
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import raptorbt
|
||||
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
|
||||
# Create batch items — one per strategy variation
|
||||
items = [
|
||||
raptorbt.PyBatchSpreadItem(
|
||||
strategy_id="straddle_24000",
|
||||
legs_premiums=[call_24000_premiums, put_24000_premiums],
|
||||
leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)],
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
spread_type="straddle",
|
||||
max_loss=5000.0,
|
||||
target_profit=3000.0,
|
||||
),
|
||||
raptorbt.PyBatchSpreadItem(
|
||||
strategy_id="strangle_23500_24500",
|
||||
legs_premiums=[call_24500_premiums, put_23500_premiums],
|
||||
leg_configs=[("CE", 24500.0, -1, 50), ("PE", 23500.0, -1, 50)],
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
spread_type="strangle",
|
||||
),
|
||||
]
|
||||
|
||||
# Run all in parallel — returns list of (strategy_id, result) tuples
|
||||
results = raptorbt.batch_spread_backtest(
|
||||
timestamps=timestamps,
|
||||
underlying_close=underlying_close,
|
||||
items=items,
|
||||
config=config,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -529,75 +649,65 @@ config.set_risk_reward_target(ratio=2.0) # 2:1 risk-reward ratio
|
||||
|
||||
---
|
||||
|
||||
## VectorBT Comparison
|
||||
## Monte Carlo Portfolio Simulation
|
||||
|
||||
RaptorBT is designed as a drop-in replacement for VectorBT. Here's a side-by-side comparison:
|
||||
|
||||
### VectorBT (before)
|
||||
RaptorBT includes a high-performance Monte Carlo forward simulation engine for portfolio risk analysis. It uses Geometric Brownian Motion (GBM) with Cholesky decomposition for correlated multi-asset simulation, parallelized via Rayon.
|
||||
|
||||
```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
|
||||
import raptorbt
|
||||
|
||||
# Configure backtest
|
||||
config = raptorbt.PyBacktestConfig(
|
||||
initial_capital=100000,
|
||||
fees=0.001,
|
||||
# Historical daily returns per strategy/asset (numpy arrays)
|
||||
returns = [
|
||||
np.array([0.001, -0.002, 0.003, ...]), # Strategy 1 returns
|
||||
np.array([0.002, 0.001, -0.001, ...]), # Strategy 2 returns
|
||||
]
|
||||
|
||||
# Portfolio weights (must sum to 1.0)
|
||||
weights = np.array([0.6, 0.4])
|
||||
|
||||
# Correlation matrix (N x N)
|
||||
correlation_matrix = [
|
||||
np.array([1.0, 0.3]),
|
||||
np.array([0.3, 1.0]),
|
||||
]
|
||||
|
||||
# Run simulation
|
||||
result = raptorbt.simulate_portfolio_mc(
|
||||
returns=returns,
|
||||
weights=weights,
|
||||
correlation_matrix=correlation_matrix,
|
||||
initial_value=100000.0,
|
||||
n_simulations=10000, # Number of Monte Carlo paths (default: 10,000)
|
||||
horizon_days=252, # Forward projection horizon (default: 252)
|
||||
seed=42, # Random seed for reproducibility (default: 42)
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
# Results
|
||||
print(f"Expected Return: {result['expected_return']:.2f}%")
|
||||
print(f"Probability of Loss: {result['probability_of_loss']:.2%}")
|
||||
print(f"VaR (95%): {result['var_95']:.2f}%")
|
||||
print(f"CVaR (95%): {result['cvar_95']:.2f}%")
|
||||
|
||||
# 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}%")
|
||||
# Percentile paths: list of (percentile, path_values)
|
||||
# Percentiles: 5th, 25th, 50th, 75th, 95th
|
||||
for pct, path in result['percentile_paths']:
|
||||
print(f" P{pct:.0f} final value: {path[-1]:.2f}")
|
||||
|
||||
# Final values: numpy array of terminal values for all simulations
|
||||
final_values = result['final_values'] # numpy array, length = n_simulations
|
||||
```
|
||||
|
||||
### Metric Mapping
|
||||
### Result Fields
|
||||
|
||||
| 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` |
|
||||
| Field | Type | Description |
|
||||
| --------------------- | -------------------------- | ---------------------------------------------------------- |
|
||||
| `expected_return` | `float` | Expected return as percentage over the horizon |
|
||||
| `probability_of_loss` | `float` | Probability that final value < initial value (0.0 to 1.0) |
|
||||
| `var_95` | `float` | Value at Risk at 95% confidence (percentage) |
|
||||
| `cvar_95` | `float` | Conditional VaR at 95% confidence (percentage) |
|
||||
| `percentile_paths` | `List[Tuple[float, List]]` | Portfolio paths at 5th, 25th, 50th, 75th, 95th percentiles |
|
||||
| `final_values` | `numpy.ndarray` | Terminal portfolio values for all simulations |
|
||||
|
||||
---
|
||||
|
||||
@@ -643,10 +753,55 @@ inst_config.set_fixed_target(0.05)
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `lot_size` - Minimum tradeable quantity. Position sizes are rounded down to nearest lot_size multiple. Use `1.0` for equities, `50.0` for NIFTY F&O, `0.01` for forex.
|
||||
- `alloted_capital` - Per-instrument capital cap (capped at available cash).
|
||||
- `existing_qty` / `avg_price` - Reserved for future live-to-backtest transitions.
|
||||
|
||||
### PyBatchSpreadItem
|
||||
|
||||
```python
|
||||
item = raptorbt.PyBatchSpreadItem(
|
||||
strategy_id: str, # Unique identifier for this backtest
|
||||
legs_premiums: List[np.ndarray], # Premium series per leg
|
||||
leg_configs: List[Tuple[str, float, int, int]], # (option_type, strike, quantity, lot_size)
|
||||
entries: np.ndarray, # bool entry signals
|
||||
exits: np.ndarray, # bool exit signals
|
||||
spread_type: str = "custom", # Spread type string
|
||||
max_loss: float = None, # Optional max loss exit
|
||||
target_profit: float = None, # Optional target profit exit
|
||||
)
|
||||
```
|
||||
|
||||
### batch_spread_backtest
|
||||
|
||||
```python
|
||||
results = raptorbt.batch_spread_backtest(
|
||||
timestamps: np.ndarray, # int64 nanosecond timestamps (shared)
|
||||
underlying_close: np.ndarray, # Underlying close prices (shared)
|
||||
items: List[PyBatchSpreadItem], # List of spread backtest items
|
||||
config: PyBacktestConfig = None, # Optional shared config
|
||||
) -> List[Tuple[str, PyBacktestResult]] # (strategy_id, result) pairs
|
||||
```
|
||||
|
||||
Runs all spread backtests in parallel via Rayon. Timestamps and underlying close are shared across all items and converted once. The GIL is released during execution for maximum Python concurrency.
|
||||
|
||||
### simulate_portfolio_mc
|
||||
|
||||
```python
|
||||
result = raptorbt.simulate_portfolio_mc(
|
||||
returns: List[np.ndarray], # Per-asset daily returns (N arrays)
|
||||
weights: np.ndarray, # Portfolio weights (length N, sum to 1)
|
||||
correlation_matrix: List[np.ndarray], # N x N correlation matrix
|
||||
initial_value: float, # Starting portfolio value
|
||||
n_simulations: int = 10000, # Number of Monte Carlo paths
|
||||
horizon_days: int = 252, # Forward projection horizon in days
|
||||
seed: int = 42, # Random seed for reproducibility
|
||||
) -> dict
|
||||
```
|
||||
|
||||
Returns a dictionary with keys: `expected_return`, `probability_of_loss`, `var_95`, `cvar_95`, `percentile_paths`, `final_values`.
|
||||
|
||||
### PyBacktestResult
|
||||
|
||||
```python
|
||||
@@ -699,8 +854,10 @@ 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)
|
||||
# Convert to dictionary
|
||||
stats_dict = metrics.to_dict()
|
||||
```
|
||||
|
||||
@@ -719,7 +876,7 @@ 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"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -783,44 +940,32 @@ print(f'Total Return: {result.metrics.total_return_pct:.2f}%')
|
||||
print('RaptorBT is working correctly!')
|
||||
```
|
||||
|
||||
### Comparison Test (VectorBT vs RaptorBT)
|
||||
### Verification Test
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import vectorbt as vbt
|
||||
import raptorbt
|
||||
|
||||
# Create test data
|
||||
np.random.seed(42)
|
||||
n = 500
|
||||
dates = pd.date_range('2023-01-01', periods=n, freq='D')
|
||||
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
|
||||
entries = np.zeros(n, dtype=bool)
|
||||
exits = np.zeros(n, dtype=bool)
|
||||
entries[::20] = True
|
||||
exits[10::20] = True
|
||||
|
||||
# VectorBT
|
||||
pf = vbt.Portfolio.from_signals(
|
||||
close=pd.Series(close, index=dates),
|
||||
entries=pd.Series(entries, index=dates),
|
||||
exits=pd.Series(exits, index=dates),
|
||||
init_cash=100000, fees=0.001
|
||||
)
|
||||
|
||||
# RaptorBT
|
||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||
result = raptorbt.run_single_backtest(
|
||||
timestamps=dates.astype('int64').values,
|
||||
timestamps=np.arange(n, dtype=np.int64),
|
||||
open=close, high=close, low=close, close=close,
|
||||
volume=np.ones(n), entries=entries, exits=exits,
|
||||
direction=1, weight=1.0, symbol="TEST", config=config
|
||||
)
|
||||
|
||||
print(f"VectorBT: {pf.stats()['Total Return [%]']:.4f}%")
|
||||
print(f"RaptorBT: {result.metrics.total_return_pct:.4f}%")
|
||||
# Results should match within 0.01%
|
||||
print(f"Total Return: {result.metrics.total_return_pct:.4f}%")
|
||||
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.4f}")
|
||||
print(f"Max Drawdown: {result.metrics.max_drawdown_pct:.4f}%")
|
||||
print("RaptorBT is working correctly!")
|
||||
```
|
||||
|
||||
---
|
||||
@@ -833,6 +978,56 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.4.0
|
||||
|
||||
**Tick-level backtesting — full tick resolution, no bar resampling.**
|
||||
|
||||
- Add `TickData` struct — parallel arrays of `timestamps`, `ltp`, `bid`, `ask`, `buy_qty_delta`, `sell_qty_delta`, `oi` (one element per tick). Callers must pre-convert Zerodha cumulative session totals to per-tick deltas before passing.
|
||||
- Add `ExitReason::TimeExit` — max hold-time exceeded exit for tick strategies.
|
||||
- Add `run_tick_backtest` — tick-native simulation engine. Entry fills at ask+slippage; stop/target checked against ltp on every tick (not OHLC approximation); max-hold-seconds time exit; configurable cooldown between entries. Returns the same `PyBacktestResult` / 27-metric `PyBacktestMetrics` as all other strategy types.
|
||||
- Add `compute_tick_entry_signals` — compute momentum entry bool array from precomputed feature arrays (spread gate, delta BSI gate, 1-min return gate, cooldown enforcement). O(N) single pass.
|
||||
- Add `compute_tick_exit_signals` — time-based (EOD) exit bool array from tick timestamps.
|
||||
- Add `tick_spread_pct` — per-tick bid/ask spread as percentage of mid price.
|
||||
- Add `buy_sell_imbalance_delta` — per-tick delta BSI from Zerodha cumulative running sums. Fixes the raw-cumulative BSI artefact (~0.95 all day regardless of order flow).
|
||||
- Add `return_window` — per-tick lookback return over a configurable time window using binary search (O(N log N)). Returns NaN where history is insufficient — correctly gates the entry filter rather than silently passing.
|
||||
- Add `realized_vol_rolling` — rolling realized volatility proxy (stddev of log-returns) over a time window.
|
||||
- Add `oi_position_pct` — OI position within the day's high/low range, per tick: [0, 100].
|
||||
- Add `tick_velocity` — rolling tick count per minute over a configurable time window.
|
||||
- Expose `compute_backtest_metrics` as a public free function in `portfolio::engine` — non-OHLCV strategy types can produce identical metrics without duplicating the calculation logic.
|
||||
|
||||
### v0.3.4
|
||||
|
||||
- Add single-leg option spread types: `LongCall`, `LongPut`, `NakedCall`, `NakedPut` to `SpreadType` enum
|
||||
- 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
|
||||
- Add `PyBatchSpreadItem` class for defining individual items in a batch spread backtest
|
||||
- Shared data (timestamps, underlying close) is converted once and reused across all items
|
||||
- GIL released during parallel execution for maximum Python concurrency
|
||||
- Each item carries its own `strategy_id`, leg configs, signals, spread type, and optional max loss / target profit
|
||||
- Returns a list of `(strategy_id, PyBacktestResult)` tuples preserving result-to-input mapping
|
||||
|
||||
### v0.3.2
|
||||
|
||||
- Add `payoff_ratio` metric to `BacktestMetrics` — average winning trade return divided by average losing trade return (absolute), measures risk/reward per trade
|
||||
- Add `recovery_factor` metric to `BacktestMetrics` — net profit divided by maximum drawdown in absolute terms, measures how many times over the strategy recovered from its worst drawdown
|
||||
- Both metrics computed in `StreamingMetrics::finalize()` (single-instrument backtest) and `PortfolioEngine` (multi-strategy aggregation)
|
||||
- Both metrics exposed via PyO3 as `#[pyo3(get)]` attributes on `PyBacktestMetrics`
|
||||
- Handles edge cases: returns `f64::INFINITY` when denominator is zero with positive numerator, `0.0` otherwise
|
||||
|
||||
### v0.3.1
|
||||
|
||||
- Add Monte Carlo portfolio simulation (`simulate_portfolio_mc`) for forward risk projection
|
||||
- Geometric Brownian Motion (GBM) with Cholesky decomposition for correlated multi-asset simulation
|
||||
- Rayon-parallelized simulation paths with deterministic seeding (xoshiro256\*\*)
|
||||
- Returns percentile paths (P5/P25/P50/P75/P95), VaR, CVaR, expected return, and probability of loss
|
||||
- GIL released during simulation for maximum Python concurrency
|
||||
|
||||
### v0.3.0
|
||||
|
||||
- Per-instrument configuration via `PyInstrumentConfig` (lot_size, alloted_capital, stop/target overrides)
|
||||
@@ -863,7 +1058,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
- Initial release
|
||||
- 5 strategy types: single, basket, pairs, options, multi
|
||||
- 30+ performance metrics with full VectorBT parity
|
||||
- 30+ performance metrics: Sharpe, Sortino, Calmar, Omega, SQN, profit factor, drawdown duration, and more
|
||||
- 10 technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend)
|
||||
- Stop-loss management: fixed, ATR-based, and trailing stops
|
||||
- Take-profit management: fixed, ATR-based, and risk-reward targets
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "raptorbt"
|
||||
version = "0.3.1"
|
||||
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.0"
|
||||
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,8 +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,
|
||||
@@ -43,7 +58,7 @@ from raptorbt._raptorbt import (
|
||||
rolling_max,
|
||||
)
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
__all__ = [
|
||||
# Config classes
|
||||
@@ -62,8 +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.
+47
-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.
|
||||
@@ -376,6 +418,10 @@ pub struct BacktestMetrics {
|
||||
pub avg_holding_period: f64,
|
||||
/// Exposure time percentage (time in market).
|
||||
pub exposure_pct: f64,
|
||||
/// Payoff ratio (avg win / avg loss).
|
||||
pub payoff_ratio: f64,
|
||||
/// Recovery factor (net profit / max drawdown).
|
||||
pub recovery_factor: f64,
|
||||
}
|
||||
|
||||
/// Complete backtest result.
|
||||
@@ -427,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);
|
||||
}
|
||||
}
|
||||
+17
@@ -43,10 +43,27 @@ 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>()?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::batch_spread_backtest, m)?)?;
|
||||
|
||||
// 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)?)?;
|
||||
|
||||
@@ -513,6 +513,30 @@ impl StreamingMetrics {
|
||||
let worst_trade_pct =
|
||||
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
|
||||
|
||||
// Payoff ratio: average win / average loss (absolute value)
|
||||
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
|
||||
avg_win_pct / avg_loss_pct.abs()
|
||||
} else if avg_win_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Recovery factor: net profit / max drawdown (absolute value)
|
||||
let net_profit = final_value - initial_capital;
|
||||
let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 {
|
||||
let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital;
|
||||
if max_dd_absolute > 0.0 {
|
||||
net_profit / max_dd_absolute
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else if net_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
@@ -545,6 +569,8 @@ impl StreamingMetrics {
|
||||
max_consecutive_losses: self.max_consecutive_losses,
|
||||
avg_holding_period,
|
||||
exposure_pct: 0.0, // TODO: calculate based on time in market
|
||||
payoff_ratio,
|
||||
recovery_factor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-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;
|
||||
@@ -591,6 +588,30 @@ impl PortfolioEngine {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Payoff ratio: average win / average loss (absolute value)
|
||||
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
|
||||
avg_win_pct / avg_loss_pct.abs()
|
||||
} else if avg_win_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Recovery factor: net profit / max drawdown (absolute value)
|
||||
let net_profit = end_value - start_value;
|
||||
let recovery_factor = if max_drawdown_pct > 0.0 && start_value > 0.0 {
|
||||
let max_dd_absolute = max_drawdown_pct / 100.0 * start_value;
|
||||
if max_dd_absolute > 0.0 {
|
||||
net_profit / max_dd_absolute
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else if net_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
@@ -623,6 +644,8 @@ impl PortfolioEngine {
|
||||
max_consecutive_losses,
|
||||
avg_holding_period,
|
||||
exposure_pct,
|
||||
payoff_ratio,
|
||||
recovery_factor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,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;
|
||||
|
||||
@@ -735,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;
|
||||
|
||||
+431
-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::*;
|
||||
|
||||
@@ -419,6 +420,10 @@ pub struct PyBacktestMetrics {
|
||||
pub avg_holding_period: f64,
|
||||
#[pyo3(get)]
|
||||
pub exposure_pct: f64,
|
||||
#[pyo3(get)]
|
||||
pub payoff_ratio: f64,
|
||||
#[pyo3(get)]
|
||||
pub recovery_factor: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
@@ -430,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)?;
|
||||
@@ -778,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>,
|
||||
@@ -791,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);
|
||||
@@ -820,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,
|
||||
};
|
||||
|
||||
@@ -830,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);
|
||||
@@ -838,6 +849,151 @@ pub fn run_spread_backtest<'py>(
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// A single spread backtest item for batch execution.
|
||||
#[pyclass]
|
||||
#[derive(Clone)]
|
||||
pub struct PyBatchSpreadItem {
|
||||
#[pyo3(get, set)]
|
||||
pub strategy_id: String,
|
||||
pub legs_premiums: Vec<Vec<f64>>,
|
||||
pub leg_configs: Vec<(String, f64, i32, usize)>,
|
||||
pub entries: Vec<bool>,
|
||||
pub exits: Vec<bool>,
|
||||
#[pyo3(get, set)]
|
||||
pub spread_type: String,
|
||||
#[pyo3(get, set)]
|
||||
pub max_loss: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub target_profit: Option<f64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBatchSpreadItem {
|
||||
#[new]
|
||||
#[pyo3(signature = (strategy_id, legs_premiums, leg_configs, entries, exits,
|
||||
spread_type="custom", max_loss=None, target_profit=None))]
|
||||
fn new(
|
||||
strategy_id: String,
|
||||
legs_premiums: Vec<PyReadonlyArray1<f64>>,
|
||||
leg_configs: Vec<(String, f64, i32, usize)>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
spread_type: &str,
|
||||
max_loss: Option<f64>,
|
||||
target_profit: Option<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
strategy_id,
|
||||
legs_premiums: legs_premiums.into_iter().map(numpy_to_vec_f64).collect(),
|
||||
leg_configs,
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
spread_type: spread_type.to_string(),
|
||||
max_loss,
|
||||
target_profit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multiple spread backtests in parallel via Rayon.
|
||||
///
|
||||
/// Shared data (timestamps, underlying_close) is converted once, then each
|
||||
/// item is backtested on its own Rayon thread with the GIL released.
|
||||
///
|
||||
/// Returns a Vec of (strategy_id, PyBacktestResult) tuples.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, underlying_close, items, config=None))]
|
||||
pub fn batch_spread_backtest(
|
||||
py: Python<'_>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
underlying_close: PyReadonlyArray1<f64>,
|
||||
items: Vec<PyBatchSpreadItem>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
) -> PyResult<Vec<(String, PyBacktestResult)>> {
|
||||
use rayon::prelude::*;
|
||||
|
||||
// Convert shared data while holding GIL
|
||||
let ts = numpy_to_vec_i64(timestamps);
|
||||
let underlying = numpy_to_vec_f64(underlying_close);
|
||||
let base_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
|
||||
|
||||
// Prepare each item into a self-contained struct for parallel execution
|
||||
struct PreparedItem {
|
||||
strategy_id: String,
|
||||
premiums: Vec<Vec<f64>>,
|
||||
entries: Vec<bool>,
|
||||
exits: Vec<bool>,
|
||||
spread_config: SpreadConfig,
|
||||
}
|
||||
|
||||
let prepared: Vec<PreparedItem> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let rust_leg_configs: Vec<LegConfig> = item
|
||||
.leg_configs
|
||||
.into_iter()
|
||||
.map(|(opt_type, strike, quantity, lot_size)| {
|
||||
let option_type =
|
||||
SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call);
|
||||
LegConfig::new(option_type, strike, quantity, lot_size)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let spread_type_enum = match item.spread_type.to_lowercase().as_str() {
|
||||
"straddle" => SpreadType::Straddle,
|
||||
"strangle" => SpreadType::Strangle,
|
||||
"vertical_call" | "verticalcall" => SpreadType::VerticalCall,
|
||||
"vertical_put" | "verticalput" => SpreadType::VerticalPut,
|
||||
"iron_condor" | "ironcondor" => SpreadType::IronCondor,
|
||||
"iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly,
|
||||
"butterfly_call" | "butterflycall" => SpreadType::ButterflyCall,
|
||||
"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,
|
||||
};
|
||||
|
||||
let spread_config = SpreadConfig {
|
||||
base: base_config.clone(),
|
||||
spread_type: spread_type_enum,
|
||||
leg_configs: rust_leg_configs.clone(),
|
||||
max_loss: item.max_loss,
|
||||
target_profit: item.target_profit,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps: None,
|
||||
};
|
||||
|
||||
PreparedItem {
|
||||
strategy_id: item.strategy_id,
|
||||
premiums: item.legs_premiums,
|
||||
entries: item.entries,
|
||||
exits: item.exits,
|
||||
spread_config,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Release GIL and run all backtests in parallel via Rayon
|
||||
let results: Vec<(String, crate::core::types::BacktestResult)> = py.allow_threads(|| {
|
||||
prepared
|
||||
.into_par_iter()
|
||||
.map(|item| {
|
||||
let backtest = SpreadBacktest::new(item.spread_config);
|
||||
let result =
|
||||
backtest.run(&ts, &underlying, &item.premiums, &item.entries, &item.exits);
|
||||
(item.strategy_id, result)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Re-acquire GIL and convert results to Python objects
|
||||
Ok(results.into_iter().map(|(id, result)| (id, convert_result(result))).collect())
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))]
|
||||
@@ -894,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
|
||||
// ============================================================================
|
||||
@@ -1203,6 +1630,8 @@ fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResul
|
||||
max_consecutive_losses: result.metrics.max_consecutive_losses,
|
||||
avg_holding_period: result.metrics.avg_holding_period,
|
||||
exposure_pct: result.metrics.exposure_pct,
|
||||
payoff_ratio: result.metrics.payoff_ratio,
|
||||
recovery_factor: result.metrics.recovery_factor,
|
||||
};
|
||||
|
||||
let trades: Vec<PyTrade> = result
|
||||
|
||||
@@ -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