19 Commits

Author SHA1 Message Date
vatsal 997e234a85 Merge pull request #12 from alphabench/feat/refining-metrics-for-portfolio
feat: add batch spread backtest with parallel execution, bump to 0.3.3
2026-02-25 15:21:29 +05:30
porcelaincode e049a2f968 feat: add batch spread backtest with parallel execution, bump to 0.3.3
Introduces PyBatchSpreadItem and batch_spread_backtest function for running multiple spread strategies in parallel using Rayon, enabling efficient multi-strategy backtesting workflows.
2026-02-25 15:19:42 +05:30
vatsal f7592d5d79 Merge pull request #11 from alphabench/feat/refining-metrics-for-portfolio
docs: remove iframe from README, post1 release
2026-02-18 19:17:46 +05:30
porcelaincode 87545683eb docs: remove iframe from README, post1 release 2026-02-18 19:15:15 +05:30
vatsal eb5335e809 Merge pull request #10 from alphabench/feat/refining-metrics-for-portfolio
feat: add payoff_ratio and recovery_factor metrics, bump to 0.3.2
2026-02-18 18:58:08 +05:30
porcelaincode 0ff67e7fe2 feat: add payoff_ratio and recovery_factor metrics, bump to 0.3.2
Add two new risk/reward metrics to BacktestMetrics:
- payoff_ratio: avg winning return / avg losing return (absolute)
- recovery_factor: net profit / max drawdown in absolute terms
Computed in both StreamingMetrics::finalize() and PortfolioEngine.
Exposed via PyO3 with #[pyo3(get)] on PyBacktestMetrics.
Updated README with API reference and changelog.
2026-02-18 18:57:42 +05:30
vatsal ab568cc9fb Merge pull request #9 from alphabench/feat/porfolio-simulation
feat: add Monte Carlo portfolio simulation and bump to 0.3.1
2026-02-17 00:36:45 +05:30
porcelaincode 4d4ea2e5e9 feat: add Monte Carlo portfolio simulation and bump to 0.3.1
- Add Monte Carlo forward simulation using Geometric Brownian Motion
- Support correlated multi-asset simulation with Cholesky decomposition
- Implement parallel execution via Rayon for performance
- Expose simulate_portfolio_mc function in Python bindings
- Update version from 0.3.0 to 0.3.1 across all project files
2026-02-17 00:35:20 +05:30
vatsal a82ddc598a Merge pull request #8 from alphabench/feat/instrument-level-config
feat: add instrument level config and bump to 0.3.0
2026-02-10 05:05:38 +05:30
porcelaincode bb6ce05c57 feat: add instrument level config and bump to 0.3.0 2026-02-10 05:03:39 +05:30
vatsal aad09da117 Merge pull request #7 from alphabench/fix/spread-backtest
feat: add new backtest functions
2026-02-08 06:06:30 +05:30
porcelaincode c711e9ce8e feat: add new backtest functions 2026-02-08 06:04:43 +05:30
vatsal 4d0d4cbfa3 feat: update version to 0.2.1 and add rolling min/max indicators (#6)
* feat: add session tracking and multi-leg spread backtesting

Add SessionTracker for trading session management:                             - Market hours detection (pre-open, trading, squareoff, post-close)
 - Session boundary tracking with configurable timezone    - Squareoff time support for intraday strategies                                   - Session high/low/open price tracking

Add SpreadBacktest for multi-leg options strategies:                                  - Support for straddles, strangles, vertical spreads, iron condors                                      - Coordinated entry/exit across all legs                                             - Net premium P&L calculation with max loss/target profit exits                                            - Helper functions for common spread configurations

Extend StreamingMetrics for backtest integration:                                - Add equity and drawdown tracking (update_equity, current_drawdown_pct)           - Add trade recording (record_trade, record_fees)                              - Add finalize() method to produce BacktestMetrics                        - Add with_initial_capital() constructor

Bump version to 0.2.0.

* chore: bump up version to 0.2.0

* feat: update version to 0.2.1 and add rolling min/max indicators

* fix: formatting
2026-02-02 04:24:45 +05:30
vatsal 03ac0192b7 Merge pull request #5 from alphabench/chore/fix-readme
chore: fix README for relevance
2026-01-30 03:54:47 +05:30
porcelaincode baa87b476b chore: fix README for relevance 2026-01-30 03:54:14 +05:30
vatsal f4a40617c0 Merge pull request #4 from alphabench/chore/project-metadata
update project metadata
2026-01-29 23:12:29 +05:30
porcelaincode 196f63f5c3 update project metadata and documentation for clarity and branding 2026-01-29 23:11:15 +05:30
vatsal 5ca413ebbf Fix/release workflow python (#3)
* Fix release workflow: Python compatibility issues

- Linux: Add --find-interpreter for cross-compilation
- macOS/Windows: Pin Python to 3.12 (PyO3 0.20.3 max supported)

* Fix Linux release: limit Python versions to 3.10-3.12

PyO3 0.20.3 only supports up to Python 3.12. Using --find-interpreter detected Python 3.13/3.14 in the manylinux container which caused build failures.
2026-01-28 16:05:18 +05:30
vatsal ca2965aade Fix release workflow: Python compatibility issues (#2)
- Linux: Add --find-interpreter for cross-compilation
- macOS/Windows: Pin Python to 3.12 (PyO3 0.20.3 max supported)
2026-01-28 15:54:37 +05:30
25 changed files with 2917 additions and 137 deletions
+11 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist
args: --release --out dist -i python3.10 -i python3.11 -i python3.12
manylinux: auto
- name: Upload wheels
@@ -46,6 +46,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
@@ -67,6 +72,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
Generated
+1 -1
View File
@@ -502,7 +502,7 @@ dependencies = [
[[package]]
name = "raptorbt"
version = "0.1.0"
version = "0.3.3"
dependencies = [
"approx",
"criterion",
+8 -3
View File
@@ -1,10 +1,15 @@
[package]
name = "raptorbt"
version = "0.1.0"
version = "0.3.3"
edition = "2021"
description = "High-performance Rust backtesting engine for Quant5"
authors = ["Quant5 team"]
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
authors = ["Alphabench <contact@alphabench.in>"]
license = "MIT"
repository = "https://github.com/alphabench/raptorbt"
homepage = "https://www.alphabench.in/raptorbt"
readme = "README.md"
keywords = ["backtesting", "trading", "quantitative-finance", "rust", "python"]
categories = ["finance", "simulation"]
[lib]
name = "raptorbt"
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Quant5 team
Copyright (c) 2024 Alphabench
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+360 -95
View File
@@ -1,6 +1,51 @@
# RaptorBT
**RaptorBT** is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It serves as a drop-in replacement for VectorBT, providing significant performance improvements while maintaining full metric parity.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI version](https://img.shields.io/pypi/v/raptorbt.svg)](https://pypi.org/project/raptorbt/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Rust](https://img.shields.io/badge/rust-1.70+-red.svg)](https://www.rust-lang.org/)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/raptorbt?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/raptorbt)
**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.
<p align="center">
<strong>5,800x faster</strong> · <strong>45x smaller</strong> · <strong>100% deterministic</strong>
</p>
---
### Quick Install
```bash
pip install raptorbt
```
### 30-Second Example
```python
import numpy as np
import raptorbt
# Configure
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
# Run backtest
result = raptorbt.run_single_backtest(
timestamps=timestamps, open=open, high=high, low=low, close=close,
volume=volume, entries=entries, exits=exits,
direction=1, weight=1.0, symbol="AAPL", config=config,
)
# Results
print(f"Return: {result.metrics.total_return_pct:.2f}%")
print(f"Sharpe: {result.metrics.sharpe_ratio:.2f}")
```
---
Developed and maintained by the [Alphabench](https://alphabench.in) team.
## Table of Contents
@@ -13,8 +58,7 @@
- [Metrics](#metrics)
- [Indicators](#indicators)
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
- [Python Integration](#python-integration)
- [VectorBT Drop-in Replacement](#vectorbt-drop-in-replacement)
- [VectorBT Comparison](#vectorbt-comparison)
- [API Reference](#api-reference)
- [Building from Source](#building-from-source)
- [Testing](#testing)
@@ -23,7 +67,7 @@
## Overview
RaptorBT was built to address the performance limitations of VectorBT in production environments:
RaptorBT was built to address the performance limitations of VectorBT. Benchmarked by the Alphabench team:
| Metric | VectorBT | RaptorBT | Improvement |
| ----------------------------- | ------------------- | ------------ | ------------------------- |
@@ -35,9 +79,11 @@ RaptorBT was built to address the performance limitations of VectorBT in product
### 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
- **6 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, and multi-strategy
- **Batch Spread Backtesting**: Run multiple spread backtests in parallel via Rayon with GIL released
- **Monte Carlo Simulation**: Correlated multi-asset forward projection via GBM + Cholesky decomposition
- **33 Metrics**: Full parity with VectorBT including Sharpe, Sortino, Calmar, Omega, SQN, Payoff Ratio, Recovery Factor, and more
- **12 Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min, Rolling Max
- **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
@@ -83,6 +129,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
@@ -90,6 +137,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
@@ -97,7 +145,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
@@ -114,6 +163,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
@@ -221,6 +276,9 @@ trades = result.trades() # Returns list of PyTrade objects
Basic long or short strategy on a single instrument.
```python
# Optional: Instrument-specific configuration
inst_config = raptorbt.PyInstrumentConfig(lot_size=1.0)
result = raptorbt.run_single_backtest(
timestamps=timestamps,
open=open_prices, high=high_prices, low=low_prices,
@@ -230,6 +288,7 @@ result = raptorbt.run_single_backtest(
weight=1.0,
symbol="SYMBOL",
config=config,
instrument_config=inst_config, # Optional: lot_size rounding, capital caps
)
```
@@ -244,10 +303,18 @@ instruments = [
(timestamps, open3, high3, low3, close3, volume3, entries3, exits3, 1, 0.34, "MSFT"),
]
# Optional: Per-instrument configs for lot_size and capital allocation
instrument_configs = {
"AAPL": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=33000),
"GOOGL": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=33000),
"MSFT": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=34000),
}
result = raptorbt.run_basket_backtest(
instruments=instruments,
config=config,
sync_mode="all", # "all", "any", "majority", "master"
instrument_configs=instrument_configs, # Optional
)
```
@@ -338,6 +405,50 @@ 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}%")
```
---
## Metrics
@@ -473,93 +584,137 @@ config.set_risk_reward_target(ratio=2.0) # 2:1 risk-reward ratio
---
## Python Integration
## Monte Carlo Portfolio Simulation
RaptorBT integrates seamlessly with the Quant5 golf runner through `rpbt.py`.
### Enable RaptorBT
```bash
export USE_RAPTORBT=1
```
Or in Python:
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 os
os.environ["USE_RAPTORBT"] = "1"
```
import numpy as np
import raptorbt
### Integration Functions
# 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
]
```python
from app.engine.golf.rpbt import (
is_raptorbt_enabled,
RaptorBTConfig,
RaptorBTPortfolioWrapper,
run_single_backtest_raptorbt,
run_basket_backtest_raptorbt,
run_pairs_backtest_raptorbt,
run_options_backtest_raptorbt,
run_multi_backtest_raptorbt,
# 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)
)
# Check if RaptorBT is enabled
if is_raptorbt_enabled():
print("Using RaptorBT backend")
# 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}%")
# 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
```
### Result Fields
| 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 |
---
## VectorBT Drop-in Replacement
## VectorBT Comparison
RaptorBT provides a `RaptorBTPortfolioWrapper` that mimics the VectorBT Portfolio interface:
RaptorBT is designed as a drop-in replacement for VectorBT. Here's a side-by-side comparison:
### VectorBT (before)
```python
from app.engine.golf.rpbt import (
RaptorBTPortfolioWrapper,
run_single_backtest_raptorbt,
RaptorBTConfig,
import vectorbt as vbt
import pandas as pd
# Run backtest
pf = vbt.Portfolio.from_signals(
close=close_series,
entries=entries,
exits=exits,
init_cash=100000,
fees=0.001,
)
# Get metrics
print(pf.stats()["Total Return [%]"])
print(pf.stats()["Sharpe Ratio"])
print(pf.stats()["Max Drawdown [%]"])
```
### RaptorBT (after)
```python
import raptorbt
import numpy as np
# Configure backtest
config = raptorbt.PyBacktestConfig(
initial_capital=100000,
fees=0.001,
)
# Run backtest
result = run_single_backtest_raptorbt(compiled, ohlcv_df, config, symbol)
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,
)
# Wrap result for VectorBT compatibility
portfolio = RaptorBTPortfolioWrapper(result)
# Use like VectorBT Portfolio
stats = portfolio.stats() # Returns pd.Series with VectorBT-format keys
equity = portfolio.value() # Returns equity curve as pd.Series
dd = portfolio.drawdown() # Returns drawdown curve as pd.Series
trades_df = portfolio.trades() # Returns trades as pd.DataFrame
# Access properties
print(portfolio.total_return) # Total return percentage
print(portfolio.sharpe_ratio) # Sharpe ratio
print(portfolio.max_drawdown) # Max drawdown percentage
print(portfolio.win_rate) # Win rate percentage
print(portfolio.profit_factor) # Profit factor
print(portfolio.sqn) # System Quality Number
print(portfolio.expectancy) # Expected value per trade
print(portfolio.omega_ratio) # Omega ratio
# 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}%")
```
### Stats Format
### Metric Mapping
The `stats()` method returns a pandas Series with VectorBT-compatible keys:
```python
stats = portfolio.stats()
print(stats["Total Return [%]"])
print(stats["Sharpe Ratio"])
print(stats["Max Drawdown [%]"])
print(stats["Win Rate [%]"])
print(stats["Profit Factor"])
print(stats["SQN"])
print(stats["Omega Ratio"])
# ... and 20+ more metrics
```
| 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` |
---
@@ -586,6 +741,74 @@ config.set_atr_target(multiplier: float, period: int)
config.set_risk_reward_target(ratio: float)
```
### PyInstrumentConfig
Per-instrument configuration for position sizing and risk management.
```python
inst_config = raptorbt.PyInstrumentConfig(
lot_size=1.0, # Min tradeable quantity (1 for equity, 50 for NIFTY F&O)
alloted_capital=50000.0, # Capital allocated to this instrument (optional)
existing_qty=None, # Existing position quantity (future use)
avg_price=None, # Existing position avg price (future use)
)
# Optional: per-instrument stop/target overrides
inst_config.set_fixed_stop(0.02)
inst_config.set_trailing_stop(0.03)
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
@@ -638,6 +861,8 @@ metrics.max_consecutive_wins
metrics.max_consecutive_losses
metrics.exposure_pct
metrics.open_trade_pnl
metrics.payoff_ratio # avg win / avg loss (risk/reward per trade)
metrics.recovery_factor # net profit / max drawdown (resilience)
# Convert to dictionary (VectorBT format)
stats_dict = metrics.to_dict()
@@ -686,12 +911,6 @@ maturin build --release
pip install target/wheels/raptorbt-*.whl
```
### Using the Build Script
```bash
./scripts/build-engine.sh --install
```
---
## Testing
@@ -705,9 +924,7 @@ cargo test
### Python Integration Tests
```bash
# Test basic functionality
uv run python -c "
```python
import raptorbt
import numpy as np
@@ -728,13 +945,11 @@ result = raptorbt.run_single_backtest(
)
print(f'Total Return: {result.metrics.total_return_pct:.2f}%')
print('RaptorBT is working correctly!')
"
```
### Comparison Test (VectorBT vs RaptorBT)
```bash
USE_RAPTORBT=1 uv run python << 'EOF'
```python
import numpy as np
import pandas as pd
import vectorbt as vbt
@@ -769,26 +984,76 @@ result = raptorbt.run_single_backtest(
print(f"VectorBT: {pf.stats()['Total Return [%]']:.4f}%")
print(f"RaptorBT: {result.metrics.total_return_pct:.4f}%")
print(f"Match: {abs(pf.stats()['Total Return [%]'] - result.metrics.total_return_pct) < 0.01}")
EOF
# Results should match within 0.01%
```
---
## License
RaptorBT is proprietary software developed for the Quant5 platform.
MIT License - see [LICENSE](LICENSE) for details.
---
## Changelog
### v0.1.0 (2024-01)
### 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)
- Position sizes now correctly rounded to lot_size multiples
- Support for per-instrument capital allocation in basket backtests
- Future-ready fields: existing_qty, avg_price for live-to-backtest transitions
### v0.2.2
- Export `run_spread_backtest` Python binding for multi-leg options spread strategies
- Export `rolling_min` and `rolling_max` indicator functions to Python
### v0.2.1
- Add `rolling_min` and `rolling_max` indicators for LLV (Lowest Low Value) and HHV (Highest High Value) support
- NaN handling for warmup period
### v0.2.0
- Add multi-leg spread backtesting (`run_spread_backtest`) supporting straddles, strangles, vertical spreads, iron condors, iron butterflies, butterfly spreads, calendar spreads, and diagonal spreads
- Coordinated entry/exit across all legs with net premium P&L calculation
- Max loss and target profit exit thresholds for spreads
- Add `SessionTracker` for intraday session management: market hours detection, squareoff time enforcement, session high/low/open tracking
- Pre-built session configs for NSE equity (9:15-15:30), MCX commodity (9:00-23:30), and CDS currency (9:00-17:00)
- Extend `StreamingMetrics` with equity/drawdown tracking, trade recording, and `finalize()` method
### v0.1.0
- Initial release
- 5 strategy types: single, basket, pairs, options, multi
- 30+ performance metrics
- 10 technical indicators
- Fixed, ATR, and trailing stops
- PyO3 Python bindings
- VectorBT-compatible wrapper
- 30+ performance metrics with full VectorBT parity
- 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
- PyO3 Python bindings for seamless Python integration
+5 -5
View File
@@ -4,13 +4,13 @@ build-backend = "maturin"
[project]
name = "raptorbt"
version = "0.1.0"
description = "High-performance Rust backtesting engine with Python bindings"
version = "0.3.3"
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
readme = "README.md"
requires-python = ">=3.10"
license = {file = "LICENSE"}
authors = [
{name = "Quant5 team"}
{name = "Alphabench", email = "contact@alphabench.in"}
]
keywords = [
"backtesting",
@@ -39,9 +39,9 @@ classifiers = [
]
[project.urls]
Homepage = "https://github.com/alphabench/raptorbt"
Homepage = "https://www.alphabench.in/raptorbt"
Repository = "https://github.com/alphabench/raptorbt"
Documentation = "https://github.com/alphabench/raptorbt#readme"
Documentation = "https://www.alphabench.in/raptorbt"
"Bug Tracker" = "https://github.com/alphabench/raptorbt/issues"
[tool.maturin]
+20 -2
View File
@@ -1,5 +1,5 @@
"""
RaptorBT - High-performance Rust backtesting engine for Quant5.
RaptorBT - High-performance Rust backtesting engine.
This module provides Python bindings for the Rust-based backtesting engine,
offering significant performance improvements over vectorbt:
@@ -12,6 +12,7 @@ offering significant performance improvements over vectorbt:
from raptorbt._raptorbt import (
# Config classes
PyBacktestConfig,
PyInstrumentConfig,
PyStopConfig,
PyTargetConfig,
# Result classes
@@ -24,6 +25,12 @@ from raptorbt._raptorbt import (
run_options_backtest,
run_pairs_backtest,
run_multi_backtest,
run_spread_backtest,
# Batch backtest
PyBatchSpreadItem,
batch_spread_backtest,
# Monte Carlo simulation
simulate_portfolio_mc,
# Indicator functions
sma,
ema,
@@ -35,13 +42,16 @@ from raptorbt._raptorbt import (
adx,
vwap,
supertrend,
rolling_min,
rolling_max,
)
__version__ = "0.1.0"
__version__ = "0.3.3"
__all__ = [
# Config classes
"PyBacktestConfig",
"PyInstrumentConfig",
"PyStopConfig",
"PyTargetConfig",
# Result classes
@@ -54,6 +64,12 @@ __all__ = [
"run_options_backtest",
"run_pairs_backtest",
"run_multi_backtest",
"run_spread_backtest",
# Batch backtest
"PyBatchSpreadItem",
"batch_spread_backtest",
# Monte Carlo simulation
"simulate_portfolio_mc",
# Indicator functions
"sma",
"ema",
@@ -65,4 +81,6 @@ __all__ = [
"adx",
"vwap",
"supertrend",
"rolling_min",
"rolling_max",
]
Binary file not shown.
Binary file not shown.
+2
View File
@@ -1,9 +1,11 @@
//! Core types and utilities for RaptorBT.
pub mod error;
pub mod session;
pub mod timeseries;
pub mod types;
pub use error::{RaptorError, Result};
pub use session::{SessionConfig, SessionTracker};
pub use timeseries::TimeSeries;
pub use types::*;
+397
View File
@@ -0,0 +1,397 @@
//! Session tracking for intraday strategies.
//!
//! Handles:
//! - Session boundary detection (market open/close)
//! - Squareoff time enforcement
//! - Session high/low tracking for ORB and session-based indicators
//! - Timezone handling for IST (India Standard Time)
use serde::{Deserialize, Serialize};
/// Session configuration for trading hours.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
/// Market open hour (24-hour format).
pub market_open_hour: u32,
/// Market open minute.
pub market_open_minute: u32,
/// Market close hour (24-hour format).
pub market_close_hour: u32,
/// Market close minute.
pub market_close_minute: u32,
/// Squareoff minutes before market close.
pub squareoff_minutes_before_close: u32,
/// Timezone offset in hours from UTC (5 for IST = UTC+5:30).
pub timezone_offset_hours: i32,
/// Timezone offset minutes (30 for IST).
pub timezone_offset_minutes: i32,
}
impl Default for SessionConfig {
fn default() -> Self {
// Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25)
Self {
market_open_hour: 9,
market_open_minute: 15,
market_close_hour: 15,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
}
impl SessionConfig {
/// Create NSE equity session config (9:15 - 15:30).
pub fn nse_equity() -> Self {
Self::default()
}
/// Create MCX commodity session config (9:00 - 23:30).
pub fn mcx_commodity() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 23,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Create CDS currency session config (9:00 - 17:00).
pub fn cds_currency() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 17,
market_close_minute: 0,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Get market open time in minutes from midnight.
pub fn market_open_minutes(&self) -> u32 {
self.market_open_hour * 60 + self.market_open_minute
}
/// Get market close time in minutes from midnight.
pub fn market_close_minutes(&self) -> u32 {
self.market_close_hour * 60 + self.market_close_minute
}
/// Get squareoff time in minutes from midnight.
pub fn squareoff_minutes(&self) -> u32 {
self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close)
}
/// Get timezone offset in seconds.
pub fn timezone_offset_seconds(&self) -> i64 {
(self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60)
}
}
/// Session tracker for managing intraday session state.
#[derive(Debug, Clone)]
pub struct SessionTracker {
config: SessionConfig,
/// Current session date (days since epoch in local timezone).
current_session_date: i64,
/// Session high price.
session_high: f64,
/// Session low price.
session_low: f64,
/// Session open price.
session_open: f64,
/// Bar index at session start.
session_start_idx: usize,
/// Whether we're currently in a trading session.
in_session: bool,
/// Whether squareoff has been triggered today.
squareoff_triggered: bool,
}
impl SessionTracker {
/// Create a new session tracker.
pub fn new(config: SessionConfig) -> Self {
Self {
config,
current_session_date: -1,
session_high: f64::NEG_INFINITY,
session_low: f64::INFINITY,
session_open: 0.0,
session_start_idx: 0,
in_session: false,
squareoff_triggered: false,
}
}
/// Convert nanosecond timestamp to local time components.
fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) {
// Convert to seconds
let timestamp_s = timestamp_ns / 1_000_000_000;
// Apply timezone offset
let local_s = timestamp_s + self.config.timezone_offset_seconds();
// Calculate date (days since epoch)
let days = local_s / 86400;
// Calculate time within day
let time_in_day = (local_s % 86400) as u32;
let hours = time_in_day / 3600;
let minutes = (time_in_day % 3600) / 60;
let seconds = time_in_day % 60;
(days, hours, minutes, seconds)
}
/// Get minutes from midnight for a timestamp.
fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 {
let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
hours * 60 + minutes
}
/// Check if timestamp is within trading hours.
pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes()
}
/// Check if it's squareoff time.
pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.squareoff_minutes()
}
/// Check if this bar starts a new session.
pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool {
let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns);
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
// New day
if curr_date != prev_date {
let curr_minutes = curr_h * 60 + curr_m;
return curr_minutes >= self.config.market_open_minutes();
}
// Same day, but crossed market open
let prev_minutes = prev_h * 60 + prev_m;
let curr_minutes = curr_h * 60 + curr_m;
prev_minutes < self.config.market_open_minutes()
&& curr_minutes >= self.config.market_open_minutes()
}
/// Check if this bar ends the session.
pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option<i64>) -> bool {
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
let curr_minutes = curr_h * 60 + curr_m;
// At or past market close
if curr_minutes >= self.config.market_close_minutes() {
return true;
}
// Check if next bar is in a new session
if let Some(next_ts) = next_ts_ns {
let (next_date, _, _, _) = self.timestamp_to_local(next_ts);
if next_date != curr_date {
return true;
}
}
false
}
/// Update session state for a new bar.
///
/// Returns tuple of (is_new_session, is_squareoff_time, is_session_end).
pub fn update(
&mut self,
idx: usize,
timestamp_ns: i64,
open: f64,
high: f64,
low: f64,
_close: f64,
prev_timestamp_ns: Option<i64>,
next_timestamp_ns: Option<i64>,
) -> (bool, bool, bool) {
let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
let time_minutes = hours * 60 + minutes;
// Check for new session
let is_new_session = if self.current_session_date != date {
// New date - check if within trading hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
} else if let Some(prev_ts) = prev_timestamp_ns {
if self.is_session_start(prev_ts, timestamp_ns) {
self.reset_session(idx, date, open);
true
} else {
false
}
} else {
// First bar - start session if within hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
};
// Update session high/low
if self.in_session {
if high > self.session_high {
self.session_high = high;
}
if low < self.session_low {
self.session_low = low;
}
}
// Check squareoff time
let is_squareoff =
if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered {
self.squareoff_triggered = true;
self.in_session
} else {
false
};
// Check session end
let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns);
if is_session_end {
self.in_session = false;
}
(is_new_session, is_squareoff, is_session_end)
}
/// Reset session state for a new trading day.
fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) {
self.current_session_date = date;
self.session_start_idx = idx;
self.session_open = open_price;
self.session_high = open_price;
self.session_low = open_price;
self.in_session = true;
self.squareoff_triggered = false;
}
/// Get current session high.
pub fn session_high(&self) -> f64 {
self.session_high
}
/// Get current session low.
pub fn session_low(&self) -> f64 {
self.session_low
}
/// Get current session open.
pub fn session_open(&self) -> f64 {
self.session_open
}
/// Get session start index.
pub fn session_start_idx(&self) -> usize {
self.session_start_idx
}
/// Check if currently in a trading session.
pub fn in_session(&self) -> bool {
self.in_session
}
/// Get opening range (high - low) for the session.
pub fn opening_range(&self) -> f64 {
self.session_high - self.session_low
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
// Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds
// This is approximate for testing
let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64;
let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60;
// Subtract IST offset to get UTC
let utc_seconds = seconds - (5 * 3600 + 30 * 60);
utc_seconds * 1_000_000_000
}
#[test]
fn test_session_config_defaults() {
let config = SessionConfig::default();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_open_minute, 15);
assert_eq!(config.market_close_hour, 15);
assert_eq!(config.market_close_minute, 30);
assert_eq!(config.squareoff_minutes_before_close, 5);
}
#[test]
fn test_squareoff_minutes() {
let config = SessionConfig::default();
// 15:30 - 5 minutes = 15:25 = 925 minutes
assert_eq!(config.squareoff_minutes(), 925);
}
#[test]
fn test_mcx_session() {
let config = SessionConfig::mcx_commodity();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_close_hour, 23);
assert_eq!(config.market_close_minute, 30);
}
#[test]
fn test_session_tracker_new_session() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// Simulate market open at 9:15 IST
let ts = make_timestamp(2024, 1, 15, 9, 15);
let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None);
assert!(is_new);
assert!(tracker.in_session());
assert_eq!(tracker.session_open(), 100.0);
}
#[test]
fn test_session_high_low() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// First bar
let ts1 = make_timestamp(2024, 1, 15, 9, 15);
tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None);
// Second bar
let ts2 = make_timestamp(2024, 1, 15, 9, 30);
tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None);
assert_eq!(tracker.session_high(), 110.0);
assert_eq!(tracker.session_low(), 95.0);
}
}
+91
View File
@@ -244,6 +244,47 @@ impl Default for BacktestConfig {
}
}
/// Per-instrument configuration for position sizing and risk management.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstrumentConfig {
/// Minimum tradeable quantity (1.0 for NSE EQ, 50.0 for NIFTY F&O, 0.01 for forex).
pub lot_size: Option<f64>,
/// Per-instrument capital cap.
pub alloted_capital: Option<f64>,
/// Per-instrument stop override.
pub stop: Option<StopConfig>,
/// Per-instrument target override.
pub target: Option<TargetConfig>,
/// Existing position quantity (future use).
pub existing_qty: Option<f64>,
/// Existing position average price (future use).
pub avg_price: Option<f64>,
}
impl InstrumentConfig {
/// Round a raw position size down to the nearest lot_size multiple.
/// Returns raw_size unchanged if lot_size is None or <= 0.
pub fn round_to_lot(&self, raw_size: f64) -> f64 {
match self.lot_size {
Some(lot) if lot > 0.0 => (raw_size / lot).floor() * lot,
_ => raw_size,
}
}
}
impl Default for InstrumentConfig {
fn default() -> Self {
Self {
lot_size: None,
alloted_capital: None,
stop: None,
target: None,
existing_qty: None,
avg_price: None,
}
}
}
/// Stop-loss configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum StopConfig {
@@ -335,6 +376,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.
@@ -460,3 +505,49 @@ impl Default for Position {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_round_to_lot_whole_shares() {
let config = InstrumentConfig { lot_size: Some(1.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.0);
assert_eq!(config.round_to_lot(1.0), 1.0);
assert_eq!(config.round_to_lot(0.5), 0.0);
}
#[test]
fn test_round_to_lot_nifty_fo() {
let config = InstrumentConfig { lot_size: Some(50.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.0), 200.0);
assert_eq!(config.round_to_lot(50.0), 50.0);
assert_eq!(config.round_to_lot(49.0), 0.0);
assert_eq!(config.round_to_lot(150.0), 150.0);
}
#[test]
fn test_round_to_lot_fractional() {
let config = InstrumentConfig { lot_size: Some(0.01), ..Default::default() };
assert!((config.round_to_lot(1.234) - 1.23).abs() < 1e-10);
}
#[test]
fn test_round_to_lot_none() {
let config = InstrumentConfig::default();
assert_eq!(config.round_to_lot(242.47), 242.47);
}
#[test]
fn test_round_to_lot_zero() {
let config = InstrumentConfig { lot_size: Some(0.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.47);
}
#[test]
fn test_round_to_lot_negative() {
let config = InstrumentConfig { lot_size: Some(-1.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.47);
}
}
+2
View File
@@ -4,12 +4,14 @@
//! and return Vec outputs. NaN values are used for the warmup period.
pub mod momentum;
pub mod rolling;
pub mod strength;
pub mod trend;
pub mod volatility;
pub mod volume;
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
pub use rolling::{rolling_max, rolling_min};
pub use strength::adx;
pub use trend::{ema, sma, supertrend, SupertrendResult};
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
+106
View File
@@ -0,0 +1,106 @@
//! Rolling min/max indicators for LLV/HHV support.
//!
//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV)
//! and Highest High Value (HHV) expressions.
use crate::core::error::RaptorError;
/// Calculate rolling minimum (Lowest Low Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling minimum values
pub fn rolling_min(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let min_val =
data[start..=i]
.iter()
.fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) });
result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val };
}
Ok(result)
}
/// Calculate rolling maximum (Highest High Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling maximum values
pub fn rolling_max(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let max_val =
data[start..=i]
.iter()
.fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) });
result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val };
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_min() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_min(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8)
assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2)
assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7)
assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1)
assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9)
}
#[test]
fn test_rolling_max() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_max(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8)
assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2)
assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7)
assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1)
assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9)
}
#[test]
fn test_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
assert!(rolling_min(&data, 0).is_err());
assert!(rolling_max(&data, 0).is_err());
}
}
+12 -1
View File
@@ -1,7 +1,7 @@
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
#![allow(non_local_definitions)]
//! RaptorBT - High-performance Rust backtesting engine for Quant5.
//! RaptorBT - High-performance Rust backtesting engine.
//!
//! This crate provides a complete backtesting solution with:
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
@@ -27,6 +27,7 @@ pub mod strategies;
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// Register config classes
m.add_class::<python::bindings::PyBacktestConfig>()?;
m.add_class::<python::bindings::PyInstrumentConfig>()?;
m.add_class::<python::bindings::PyStopConfig>()?;
m.add_class::<python::bindings::PyTargetConfig>()?;
@@ -41,6 +42,14 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?;
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)?)?;
// 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 indicator functions
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
@@ -53,6 +62,8 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?;
Ok(())
}
+356
View File
@@ -2,9 +2,12 @@
//!
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
use crate::core::types::BacktestMetrics;
/// Streaming metrics calculator using Welford's algorithm.
///
/// Allows incremental calculation of statistics without storing all values.
/// Also tracks equity and drawdown for backtesting.
#[derive(Debug, Clone)]
pub struct StreamingMetrics {
/// Number of observations.
@@ -27,6 +30,59 @@ pub struct StreamingMetrics {
count_positive: usize,
/// Count of negative returns.
count_negative: usize,
// === Equity and drawdown tracking ===
/// Initial capital.
#[allow(dead_code)]
initial_capital: f64,
/// Peak equity value (for drawdown calculation).
peak_equity: f64,
/// Current equity value.
current_equity: f64,
/// Maximum drawdown percentage.
max_drawdown_pct: f64,
/// Current drawdown percentage.
current_drawdown: f64,
/// Bars since peak (for max drawdown duration).
bars_since_peak: usize,
/// Maximum drawdown duration in bars.
max_drawdown_duration: usize,
// === Trade tracking ===
/// Number of trades.
trade_count: usize,
/// Number of winning trades.
winning_trades: usize,
/// Number of losing trades.
losing_trades: usize,
/// Sum of winning trade P&L.
sum_wins: f64,
/// Sum of losing trade P&L.
sum_losses: f64,
/// Sum of trade return percentages.
sum_trade_returns: f64,
/// Sum of squared trade return percentages (for SQN).
sum_trade_returns_sq: f64,
/// Best trade return percentage.
best_trade_pct: f64,
/// Worst trade return percentage.
worst_trade_pct: f64,
/// Sum of winning trade durations.
sum_winning_duration: usize,
/// Sum of losing trade durations.
sum_losing_duration: usize,
/// Current consecutive wins.
current_consecutive_wins: usize,
/// Current consecutive losses.
current_consecutive_losses: usize,
/// Maximum consecutive wins.
max_consecutive_wins: usize,
/// Maximum consecutive losses.
max_consecutive_losses: usize,
/// Total holding period (bars).
total_holding_period: usize,
/// Total fees paid.
total_fees: f64,
}
impl Default for StreamingMetrics {
@@ -38,6 +94,11 @@ impl Default for StreamingMetrics {
impl StreamingMetrics {
/// Create a new streaming metrics calculator.
pub fn new() -> Self {
Self::with_initial_capital(0.0)
}
/// Create a new streaming metrics calculator with initial capital.
pub fn with_initial_capital(initial_capital: f64) -> Self {
Self {
count: 0,
mean: 0.0,
@@ -49,6 +110,32 @@ impl StreamingMetrics {
sum_negative: 0.0,
count_positive: 0,
count_negative: 0,
// Equity tracking
initial_capital,
peak_equity: initial_capital,
current_equity: initial_capital,
max_drawdown_pct: 0.0,
current_drawdown: 0.0,
bars_since_peak: 0,
max_drawdown_duration: 0,
// Trade tracking
trade_count: 0,
winning_trades: 0,
losing_trades: 0,
sum_wins: 0.0,
sum_losses: 0.0,
sum_trade_returns: 0.0,
sum_trade_returns_sq: 0.0,
best_trade_pct: f64::NEG_INFINITY,
worst_trade_pct: f64::INFINITY,
sum_winning_duration: 0,
sum_losing_duration: 0,
current_consecutive_wins: 0,
current_consecutive_losses: 0,
max_consecutive_wins: 0,
max_consecutive_losses: 0,
total_holding_period: 0,
total_fees: 0.0,
}
}
@@ -218,6 +305,275 @@ impl StreamingMetrics {
self.profit_factor()
}
// === Equity tracking methods ===
/// Update equity and calculate drawdown.
pub fn update_equity(&mut self, equity: f64) {
self.current_equity = equity;
if equity > self.peak_equity {
self.peak_equity = equity;
self.bars_since_peak = 0;
} else {
self.bars_since_peak += 1;
if self.bars_since_peak > self.max_drawdown_duration {
self.max_drawdown_duration = self.bars_since_peak;
}
}
// Calculate current drawdown percentage
if self.peak_equity > 0.0 {
self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0;
if self.current_drawdown > self.max_drawdown_pct {
self.max_drawdown_pct = self.current_drawdown;
}
}
}
/// Get current drawdown percentage.
#[inline]
pub fn current_drawdown_pct(&self) -> f64 {
self.current_drawdown
}
/// Get maximum drawdown percentage.
#[inline]
pub fn max_drawdown_pct(&self) -> f64 {
self.max_drawdown_pct
}
// === Trade tracking methods ===
/// Record a completed trade.
///
/// # Arguments
/// * `pnl` - Trade profit/loss
/// * `return_pct` - Trade return percentage
/// * `duration` - Trade duration in bars
pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) {
self.trade_count += 1;
self.sum_trade_returns += return_pct;
self.sum_trade_returns_sq += return_pct * return_pct;
self.total_holding_period += duration;
// Track best/worst trades
if return_pct > self.best_trade_pct {
self.best_trade_pct = return_pct;
}
if return_pct < self.worst_trade_pct {
self.worst_trade_pct = return_pct;
}
if pnl > 0.0 {
self.winning_trades += 1;
self.sum_wins += pnl;
self.sum_winning_duration += duration;
self.current_consecutive_wins += 1;
self.current_consecutive_losses = 0;
if self.current_consecutive_wins > self.max_consecutive_wins {
self.max_consecutive_wins = self.current_consecutive_wins;
}
} else if pnl < 0.0 {
self.losing_trades += 1;
self.sum_losses += pnl.abs();
self.sum_losing_duration += duration;
self.current_consecutive_losses += 1;
self.current_consecutive_wins = 0;
if self.current_consecutive_losses > self.max_consecutive_losses {
self.max_consecutive_losses = self.current_consecutive_losses;
}
}
}
/// Record fees paid.
pub fn record_fees(&mut self, fees: f64) {
self.total_fees += fees;
}
/// Finalize metrics and produce BacktestMetrics.
///
/// # Arguments
/// * `initial_capital` - Starting capital
/// * `final_value` - Ending portfolio value
/// * `returns` - Array of period returns for ratio calculations
pub fn finalize(
&self,
initial_capital: f64,
final_value: f64,
returns: &[f64],
) -> BacktestMetrics {
// Calculate return metrics from the returns array
let mut return_metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
return_metrics.update(r);
}
}
let total_return_pct = if initial_capital > 0.0 {
(final_value - initial_capital) / initial_capital * 100.0
} else {
0.0
};
// Calculate trade-based metrics
let win_rate_pct = if self.trade_count > 0 {
self.winning_trades as f64 / self.trade_count as f64 * 100.0
} else {
0.0
};
let profit_factor = if self.sum_losses > 0.0 {
self.sum_wins / self.sum_losses
} else if self.sum_wins > 0.0 {
f64::INFINITY
} else {
0.0
};
let avg_trade_return_pct = if self.trade_count > 0 {
self.sum_trade_returns / self.trade_count as f64
} else {
0.0
};
let avg_win_pct = if self.winning_trades > 0 {
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
} else {
0.0
};
let avg_loss_pct = if self.losing_trades > 0 {
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
} else {
0.0
};
let avg_winning_duration = if self.winning_trades > 0 {
self.sum_winning_duration as f64 / self.winning_trades as f64
} else {
0.0
};
let avg_losing_duration = if self.losing_trades > 0 {
self.sum_losing_duration as f64 / self.losing_trades as f64
} else {
0.0
};
let avg_holding_period = if self.trade_count > 0 {
self.total_holding_period as f64 / self.trade_count as f64
} else {
0.0
};
// Expectancy: average profit per trade
let expectancy = if self.trade_count > 0 {
(self.sum_wins - self.sum_losses) / self.trade_count as f64
} else {
0.0
};
// SQN (System Quality Number)
let sqn = if self.trade_count > 1 {
let mean_return = self.sum_trade_returns / self.trade_count as f64;
let variance =
(self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return);
let std_dev = variance.max(0.0).sqrt();
if std_dev > 0.0 {
(mean_return / std_dev) * (self.trade_count as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Sharpe ratio (annualized, assuming 252 trading days)
let sharpe_ratio = return_metrics.sharpe_ratio(252.0);
// Sortino ratio (annualized)
let sortino_ratio = return_metrics.sortino_ratio(252.0);
// Calmar ratio (annualized return / max drawdown)
let calmar_ratio = if self.max_drawdown_pct > 0.0 {
total_return_pct / self.max_drawdown_pct
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Omega ratio
let omega_ratio = return_metrics.omega_ratio();
// Best/worst trade handling (handle edge cases)
let best_trade_pct =
if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct };
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,
sortino_ratio,
calmar_ratio,
omega_ratio,
max_drawdown_pct: self.max_drawdown_pct,
max_drawdown_duration: self.max_drawdown_duration,
win_rate_pct,
profit_factor,
expectancy,
sqn,
total_trades: self.trade_count,
total_closed_trades: self.trade_count,
total_open_trades: 0,
open_trade_pnl: 0.0,
winning_trades: self.winning_trades,
losing_trades: self.losing_trades,
start_value: initial_capital,
end_value: final_value,
total_fees_paid: self.total_fees,
best_trade_pct,
worst_trade_pct,
avg_trade_return_pct,
avg_win_pct,
avg_loss_pct,
avg_winning_duration,
avg_losing_duration,
max_consecutive_wins: self.max_consecutive_wins,
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,
}
}
/// Reset all metrics.
pub fn reset(&mut self) {
*self = Self::new();
+98 -16
View File
@@ -2,7 +2,7 @@
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
OhlcvData, Price, StopConfig, TargetConfig, Trade,
InstrumentConfig, OhlcvData, Price, StopConfig, TargetConfig, Trade,
};
use crate::execution::{FeeModel, FillPrice, SlippageModel};
use crate::indicators::volatility::atr;
@@ -69,6 +69,24 @@ impl PortfolioEngine {
/// # Returns
/// Backtest result
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
self.run_single_with_instrument_config(ohlcv, signals, None)
}
/// Run backtest on single instrument with optional per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result
pub fn run_single_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
let n = ohlcv.len();
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
@@ -86,14 +104,20 @@ impl PortfolioEngine {
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
// Determine effective stop/target configs (per-instrument overrides take precedence)
let effective_stop =
inst_config.and_then(|ic| ic.stop.as_ref()).unwrap_or(&self.config.stop);
let effective_target =
inst_config.and_then(|ic| ic.target.as_ref()).unwrap_or(&self.config.target);
// Pre-calculate ATR for ATR-based stops
let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. })
|| matches!(self.config.target, TargetConfig::Atr { .. })
let atr_values = if matches!(effective_stop, StopConfig::Atr { .. })
|| matches!(effective_target, TargetConfig::Atr { .. })
{
let period = match self.config.stop {
StopConfig::Atr { period, .. } => period,
_ => match self.config.target {
TargetConfig::Atr { period, .. } => period,
let period = match effective_stop {
StopConfig::Atr { period, .. } => *period,
_ => match effective_target {
TargetConfig::Atr { period, .. } => *period,
_ => 14,
},
};
@@ -188,8 +212,8 @@ impl PortfolioEngine {
// Update trailing stop if position still open
if position.is_in_position() {
if let StopConfig::Trailing { percent } = self.config.stop {
position.update_trailing_stop(percent);
if let StopConfig::Trailing { percent } = effective_stop {
position.update_trailing_stop(*percent);
}
}
}
@@ -207,26 +231,37 @@ impl PortfolioEngine {
);
// Calculate position size
// Use per-instrument capital if set, capped at available cash
let available = inst_config
.and_then(|ic| ic.alloted_capital)
.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
let fee_rate = self.config.fees;
let size = if let Some(ref sizes) = signals.position_sizes {
sizes[i] * cash / (adjusted_price * (1.0 + fee_rate))
let raw_size = if let Some(ref sizes) = signals.position_sizes {
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
} else {
cash / (adjusted_price * (1.0 + fee_rate))
available / (adjusted_price * (1.0 + fee_rate))
};
// Round to lot_size
let size = inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size);
if size > 0.0 {
// Calculate entry fees
let entry_fees =
self.fee_model.calculate(adjusted_price, size, signals.direction);
// Calculate stop and target prices
let (stop_price, target_price) = self.calculate_stop_target(
let (stop_price, target_price) = self.calculate_stop_target_with_config(
adjusted_price,
signals.direction,
&atr_values,
i,
effective_stop,
effective_target,
);
// Open position (passing entry_fees for trade PnL tracking)
@@ -310,18 +345,39 @@ impl PortfolioEngine {
)
}
/// Calculate stop and target prices.
/// Calculate stop and target prices using the global config.
#[allow(dead_code)]
fn calculate_stop_target(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
) -> (Option<Price>, Option<Price>) {
self.calculate_stop_target_with_config(
entry_price,
direction,
atr_values,
idx,
&self.config.stop,
&self.config.target,
)
}
/// Calculate stop and target prices with explicit stop/target configs.
fn calculate_stop_target_with_config(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
stop_config: &StopConfig,
target_config: &TargetConfig,
) -> (Option<Price>, Option<Price>) {
let multiplier = direction.multiplier();
// Calculate stop price
let stop_price = match self.config.stop {
let stop_price = match stop_config {
StopConfig::None => None,
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
StopConfig::Atr { multiplier: m, .. } => {
@@ -336,7 +392,7 @@ impl PortfolioEngine {
};
// Calculate target price
let target_price = match self.config.target {
let target_price = match target_config {
TargetConfig::None => None,
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
TargetConfig::Atr { multiplier: m, .. } => {
@@ -535,6 +591,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,
@@ -567,6 +647,8 @@ impl PortfolioEngine {
max_consecutive_losses,
avg_holding_period,
exposure_pct,
payoff_ratio,
recovery_factor,
}
}
+2
View File
@@ -2,8 +2,10 @@
pub mod allocation;
pub mod engine;
pub mod monte_carlo;
pub mod position;
pub use allocation::{AllocationStrategy, CapitalAllocator};
pub use engine::PortfolioEngine;
pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult};
pub use position::PositionManager;
+361
View File
@@ -0,0 +1,361 @@
//! Monte Carlo forward simulation for portfolio projection.
//!
//! Uses Geometric Brownian Motion (GBM) with Cholesky decomposition
//! for correlated multi-asset simulation. Parallelized via Rayon.
use rayon::prelude::*;
/// Configuration for Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloConfig {
pub n_simulations: usize,
pub horizon_days: usize,
pub seed: u64,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self { n_simulations: 10_000, horizon_days: 252, seed: 42 }
}
}
/// Result of a Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloResult {
/// Percentile paths: Vec of (percentile, path_values)
pub percentile_paths: Vec<(f64, Vec<f64>)>,
/// Terminal value for each simulation
pub final_values: Vec<f64>,
/// Expected annualized return
pub expected_return: f64,
/// Probability of loss (final value < initial value)
pub probability_of_loss: f64,
/// Value at Risk at 95% confidence
pub var_95: f64,
/// Conditional Value at Risk at 95% confidence
pub cvar_95: f64,
}
/// Cholesky decomposition of a symmetric positive-definite matrix.
/// Returns lower-triangular matrix L such that A = L * L^T.
fn cholesky(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, &'static str> {
let n = matrix.len();
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = 0.0;
for k in 0..j {
sum += l[i][k] * l[j][k];
}
if i == j {
let diag = matrix[i][i] - sum;
if diag <= 0.0 {
// Matrix is not positive definite; use a small epsilon
l[i][j] = (diag.abs().max(1e-10)).sqrt();
} else {
l[i][j] = diag.sqrt();
}
} else {
if l[j][j].abs() < 1e-15 {
l[i][j] = 0.0;
} else {
l[i][j] = (matrix[i][j] - sum) / l[j][j];
}
}
}
}
Ok(l)
}
/// Simple xoshiro256** PRNG for deterministic parallel simulation.
#[derive(Clone)]
struct Xoshiro256 {
s: [u64; 4],
}
impl Xoshiro256 {
fn new(seed: u64) -> Self {
// SplitMix64 to seed all 4 state words
let mut z = seed;
let mut s = [0u64; 4];
for item in &mut s {
z = z.wrapping_add(0x9e3779b97f4a7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
*item = z ^ (z >> 31);
}
Self { s }
}
fn jump(&mut self) {
// Jump function: advances state by 2^128 calls
const JUMP: [u64; 4] =
[0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c];
let mut s0: u64 = 0;
let mut s1: u64 = 0;
let mut s2: u64 = 0;
let mut s3: u64 = 0;
for j in &JUMP {
for b in 0..64 {
if j & (1u64 << b) != 0 {
s0 ^= self.s[0];
s1 ^= self.s[1];
s2 ^= self.s[2];
s3 ^= self.s[3];
}
self.next_u64();
}
}
self.s[0] = s0;
self.s[1] = s1;
self.s[2] = s2;
self.s[3] = s3;
}
fn next_u64(&mut self) -> u64 {
let result = (self.s[1].wrapping_mul(5)).rotate_left(7).wrapping_mul(9);
let t = self.s[1] << 17;
self.s[2] ^= self.s[0];
self.s[3] ^= self.s[1];
self.s[1] ^= self.s[2];
self.s[0] ^= self.s[3];
self.s[2] ^= t;
self.s[3] = self.s[3].rotate_left(45);
result
}
/// Generate uniform f64 in [0, 1).
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
/// Box-Muller transform for standard normal.
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64().max(1e-15);
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
/// Core Monte Carlo simulation function.
///
/// # Arguments
/// * `returns` - Per-strategy daily returns (N strategies x T days each)
/// * `weights` - Portfolio weights (length N, must sum to 1)
/// * `correlation_matrix` - N x N correlation matrix
/// * `initial_value` - Starting portfolio value
/// * `config` - Simulation configuration
pub fn simulate_portfolio_forward(
returns: &[Vec<f64>],
weights: &[f64],
correlation_matrix: &[Vec<f64>],
initial_value: f64,
config: &MonteCarloConfig,
) -> MonteCarloResult {
let n_assets = returns.len();
let dt = 1.0; // daily time step
// Compute per-asset mean and std of historical returns
let mut mus = vec![0.0; n_assets];
let mut sigmas = vec![0.0; n_assets];
for (i, ret) in returns.iter().enumerate() {
if ret.is_empty() {
continue;
}
let mean = ret.iter().sum::<f64>() / ret.len() as f64;
let var = ret.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / ret.len() as f64;
mus[i] = mean;
sigmas[i] = var.sqrt().max(1e-10);
}
// Cholesky decomposition of correlation matrix
let chol = cholesky(correlation_matrix).unwrap_or_else(|_| {
// Fallback: identity matrix (independent assets)
let mut identity = vec![vec![0.0; n_assets]; n_assets];
for i in 0..n_assets {
identity[i][i] = 1.0;
}
identity
});
// Prepare a base RNG and create per-chunk seeds via jumping
let mut base_rng = Xoshiro256::new(config.seed);
let n_chunks = rayon::current_num_threads().max(1);
let chunk_size = (config.n_simulations + n_chunks - 1) / n_chunks;
let chunk_rngs: Vec<Xoshiro256> = (0..n_chunks)
.map(|_| {
let rng = base_rng.clone();
base_rng.jump();
rng
})
.collect();
// Run simulations in parallel chunks
let all_paths: Vec<Vec<f64>> = chunk_rngs
.into_par_iter()
.enumerate()
.flat_map(|(chunk_idx, mut rng)| {
let start = chunk_idx * chunk_size;
let end = (start + chunk_size).min(config.n_simulations);
let mut chunk_paths = Vec::with_capacity(end - start);
for _ in start..end {
let mut portfolio_value = initial_value;
let mut path = Vec::with_capacity(config.horizon_days + 1);
path.push(portfolio_value);
for _ in 0..config.horizon_days {
// Generate N independent standard normals
let z_indep: Vec<f64> = (0..n_assets).map(|_| rng.next_normal()).collect();
// Correlate via Cholesky: z_corr = L * z_indep
let mut z_corr = vec![0.0; n_assets];
for i in 0..n_assets {
for j in 0..=i {
z_corr[i] += chol[i][j] * z_indep[j];
}
}
// GBM per asset, then weighted portfolio return
let mut portfolio_return = 0.0;
for i in 0..n_assets {
let drift = (mus[i] - 0.5 * sigmas[i].powi(2)) * dt;
let diffusion = sigmas[i] * dt.sqrt() * z_corr[i];
let asset_return = (drift + diffusion).exp() - 1.0;
portfolio_return += weights[i] * asset_return;
}
portfolio_value *= 1.0 + portfolio_return;
path.push(portfolio_value);
}
chunk_paths.push(path);
}
chunk_paths
})
.collect();
// Extract final values
let mut final_values: Vec<f64> = all_paths.iter().map(|p| *p.last().unwrap()).collect();
final_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = final_values.len();
// Percentile paths: find simulations closest to each percentile's final value
let percentiles = [5.0, 25.0, 50.0, 75.0, 95.0];
let percentile_paths: Vec<(f64, Vec<f64>)> = percentiles
.iter()
.map(|&pct| {
let idx = ((pct / 100.0) * (n as f64 - 1.0)).round() as usize;
let target_final = final_values[idx.min(n - 1)];
// Find the simulation path whose final value is closest to target
let best_idx = all_paths
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let da = (a.last().unwrap() - target_final).abs();
let db = (b.last().unwrap() - target_final).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
(pct, all_paths[best_idx].clone())
})
.collect();
// Expected return (annualized from mean of final values)
let mean_final = final_values.iter().sum::<f64>() / n as f64;
let expected_return = (mean_final / initial_value - 1.0) * 100.0;
// Probability of loss
let n_loss = final_values.iter().filter(|&&v| v < initial_value).count();
let probability_of_loss = n_loss as f64 / n as f64;
// VaR 95%: 5th percentile loss
let p5_idx = ((0.05 * (n as f64 - 1.0)).round() as usize).min(n - 1);
let var_95 = ((initial_value - final_values[p5_idx]) / initial_value * 100.0).max(0.0);
// CVaR 95%: average of losses below VaR
let cvar_values = &final_values[..=p5_idx];
let cvar_95 = if cvar_values.is_empty() {
var_95
} else {
let avg_tail = cvar_values.iter().sum::<f64>() / cvar_values.len() as f64;
((initial_value - avg_tail) / initial_value * 100.0).max(0.0)
};
MonteCarloResult {
percentile_paths,
final_values,
expected_return,
probability_of_loss,
var_95,
cvar_95,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cholesky_identity() {
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let l = cholesky(&matrix).unwrap();
assert!((l[0][0] - 1.0).abs() < 1e-10);
assert!((l[1][1] - 1.0).abs() < 1e-10);
assert!(l[0][1].abs() < 1e-10);
assert!(l[1][0].abs() < 1e-10);
}
#[test]
fn test_cholesky_correlated() {
let matrix = vec![vec![1.0, 0.5], vec![0.5, 1.0]];
let l = cholesky(&matrix).unwrap();
// Verify L * L^T = matrix
let reconstructed_00 = l[0][0] * l[0][0];
let reconstructed_01 = l[1][0] * l[0][0];
let reconstructed_11 = l[1][0] * l[1][0] + l[1][1] * l[1][1];
assert!((reconstructed_00 - 1.0).abs() < 1e-10);
assert!((reconstructed_01 - 0.5).abs() < 1e-10);
assert!((reconstructed_11 - 1.0).abs() < 1e-10);
}
#[test]
fn test_simulate_basic() {
// Two assets with identical positive returns
let returns = vec![vec![0.001; 252], vec![0.001; 252]];
let weights = vec![0.5, 0.5];
let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let config = MonteCarloConfig { n_simulations: 100, horizon_days: 10, seed: 42 };
let result = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
assert_eq!(result.final_values.len(), 100);
assert_eq!(result.percentile_paths.len(), 5);
// Expected return should be positive given positive drift
assert!(result.expected_return > -50.0); // Sanity check
}
#[test]
fn test_deterministic() {
let returns = vec![vec![0.001; 100], vec![-0.0005; 100]];
let weights = vec![0.6, 0.4];
let corr = vec![vec![1.0, -0.3], vec![-0.3, 1.0]];
let config = MonteCarloConfig { n_simulations: 50, horizon_days: 20, seed: 123 };
let r1 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
let r2 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
// Same seed should produce same final values (single-threaded determinism)
// Note: with rayon, parallelism may affect order but not values
assert!((r1.expected_return - r2.expected_return).abs() < 1e-6);
}
}
+413 -5
View File
@@ -3,8 +3,11 @@
use numpy::{PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use std::collections::HashMap;
use crate::core::types::{
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig,
TargetConfig,
};
use crate::indicators;
use crate::signals::synchronizer::SyncMode;
@@ -15,6 +18,9 @@ use crate::strategies::options::{
};
use crate::strategies::pairs::{PairsBacktest, PairsConfig};
use crate::strategies::single::SingleBacktest;
use crate::strategies::spreads::{
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
};
use super::numpy_bridge::*;
@@ -97,6 +103,93 @@ impl From<&PyBacktestConfig> for BacktestConfig {
}
}
/// Python-exposed per-instrument configuration.
#[pyclass]
#[derive(Debug, Clone)]
pub struct PyInstrumentConfig {
#[pyo3(get, set)]
pub lot_size: Option<f64>,
#[pyo3(get, set)]
pub alloted_capital: Option<f64>,
#[pyo3(get, set)]
pub existing_qty: Option<f64>,
#[pyo3(get, set)]
pub avg_price: Option<f64>,
stop_config: Option<StopConfig>,
target_config: Option<TargetConfig>,
}
#[pymethods]
impl PyInstrumentConfig {
#[new]
#[pyo3(signature = (lot_size=None, alloted_capital=None, existing_qty=None, avg_price=None))]
fn new(
lot_size: Option<f64>,
alloted_capital: Option<f64>,
existing_qty: Option<f64>,
avg_price: Option<f64>,
) -> Self {
Self {
lot_size,
alloted_capital,
existing_qty,
avg_price,
stop_config: None,
target_config: None,
}
}
/// Set fixed percentage stop-loss override.
fn set_fixed_stop(&mut self, percent: f64) {
self.stop_config = Some(StopConfig::Fixed { percent });
}
/// Set ATR-based stop-loss override.
fn set_atr_stop(&mut self, multiplier: f64, period: usize) {
self.stop_config = Some(StopConfig::Atr { multiplier, period });
}
/// Set trailing stop-loss override.
fn set_trailing_stop(&mut self, percent: f64) {
self.stop_config = Some(StopConfig::Trailing { percent });
}
/// Set fixed percentage take-profit override.
fn set_fixed_target(&mut self, percent: f64) {
self.target_config = Some(TargetConfig::Fixed { percent });
}
/// Set ATR-based take-profit override.
fn set_atr_target(&mut self, multiplier: f64, period: usize) {
self.target_config = Some(TargetConfig::Atr { multiplier, period });
}
/// Set risk-reward based take-profit override.
fn set_risk_reward_target(&mut self, ratio: f64) {
self.target_config = Some(TargetConfig::RiskReward { ratio });
}
fn __repr__(&self) -> String {
format!(
"InstrumentConfig(lot_size={:?}, alloted_capital={:?})",
self.lot_size, self.alloted_capital
)
}
}
impl From<&PyInstrumentConfig> for InstrumentConfig {
fn from(py_config: &PyInstrumentConfig) -> Self {
InstrumentConfig {
lot_size: py_config.lot_size,
alloted_capital: py_config.alloted_capital,
stop: py_config.stop_config,
target: py_config.target_config,
existing_qty: py_config.existing_qty,
avg_price: py_config.avg_price,
}
}
}
/// Python-exposed stop configuration.
#[pyclass]
#[derive(Debug, Clone)]
@@ -326,6 +419,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]
@@ -416,7 +513,7 @@ impl PyBacktestResult {
/// Run single instrument backtest.
#[pyfunction]
#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None))]
#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None, instrument_config=None))]
pub fn run_single_backtest<'py>(
_py: Python<'py>,
timestamps: PyReadonlyArray1<i64>,
@@ -432,6 +529,7 @@ pub fn run_single_backtest<'py>(
symbol: &str,
config: Option<&PyBacktestConfig>,
position_sizes: Option<PyReadonlyArray1<f64>>,
instrument_config: Option<&PyInstrumentConfig>,
) -> PyResult<PyBacktestResult> {
let ohlcv = OhlcvData {
timestamps: numpy_to_vec_i64(timestamps),
@@ -454,16 +552,17 @@ pub fn run_single_backtest<'py>(
};
let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
let inst_config = instrument_config.map(InstrumentConfig::from);
let backtest = SingleBacktest::new(rust_config);
let result = backtest.run(&ohlcv, &signals);
let result = backtest.run_with_instrument_config(&ohlcv, &signals, inst_config.as_ref());
Ok(convert_result(result))
}
/// Run basket/collective backtest.
#[pyfunction]
#[pyo3(signature = (instruments, config=None, sync_mode="all"))]
#[pyo3(signature = (instruments, config=None, sync_mode="all", instrument_configs=None))]
pub fn run_basket_backtest<'py>(
_py: Python<'py>,
instruments: Vec<(
@@ -481,6 +580,7 @@ pub fn run_basket_backtest<'py>(
)>,
config: Option<&PyBacktestConfig>,
sync_mode: &str,
instrument_configs: Option<HashMap<String, PyInstrumentConfig>>,
) -> PyResult<PyBacktestResult> {
let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments
.into_iter()
@@ -518,8 +618,15 @@ pub fn run_basket_backtest<'py>(
..Default::default()
};
// Convert PyInstrumentConfig map to InstrumentConfig map
let rust_inst_configs: Option<HashMap<String, InstrumentConfig>> =
instrument_configs.map(|configs| {
configs.iter().map(|(k, v)| (k.clone(), InstrumentConfig::from(v))).collect()
});
let backtest = BasketBacktest::new(basket_config);
let result = backtest.run(&rust_instruments);
let result =
backtest.run_with_instrument_configs(&rust_instruments, rust_inst_configs.as_ref());
Ok(convert_result(result))
}
@@ -673,6 +780,208 @@ pub fn run_pairs_backtest<'py>(
Ok(convert_result(result))
}
/// 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))]
pub fn run_spread_backtest<'py>(
_py: Python<'py>,
timestamps: PyReadonlyArray1<i64>,
underlying_close: PyReadonlyArray1<f64>,
legs_premiums: Vec<PyReadonlyArray1<f64>>,
leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size)
entries: PyReadonlyArray1<bool>,
exits: PyReadonlyArray1<bool>,
config: Option<&PyBacktestConfig>,
spread_type: &str,
max_loss: Option<f64>,
target_profit: Option<f64>,
) -> PyResult<PyBacktestResult> {
let ts = numpy_to_vec_i64(timestamps);
let underlying = numpy_to_vec_f64(underlying_close);
let premiums: Vec<Vec<f64>> = legs_premiums.into_iter().map(numpy_to_vec_f64).collect();
let entry_signals = numpy_to_vec_bool(entries);
let exit_signals = numpy_to_vec_bool(exits);
// Convert leg configs
let rust_leg_configs: Vec<LegConfig> = 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();
// Parse spread type
let spread_type_enum = match 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,
_ => SpreadType::Custom,
};
let spread_config = SpreadConfig {
base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
spread_type: spread_type_enum,
leg_configs: rust_leg_configs,
max_loss,
target_profit,
close_at_eod: false,
};
let backtest = SpreadBacktest::new(spread_config);
let result = backtest.run(&ts, &underlying, &premiums, &entry_signals, &exit_signals);
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,
_ => 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,
};
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"))]
@@ -903,10 +1212,107 @@ pub fn supertrend<'py>(
Ok((vec_to_numpy_f64(py, result.supertrend), direction_array))
}
/// Rolling minimum (Lowest Low Value).
#[pyfunction]
pub fn rolling_min<'py>(
py: Python<'py>,
data: PyReadonlyArray1<f64>,
period: usize,
) -> PyResult<&'py PyArray1<f64>> {
let vec = numpy_to_vec_f64(data);
let result = indicators::rolling::rolling_min(&vec, period)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
Ok(vec_to_numpy_f64(py, result))
}
/// Rolling maximum (Highest High Value).
#[pyfunction]
pub fn rolling_max<'py>(
py: Python<'py>,
data: PyReadonlyArray1<f64>,
period: usize,
) -> PyResult<&'py PyArray1<f64>> {
let vec = numpy_to_vec_f64(data);
let result = indicators::rolling::rolling_max(&vec, period)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
Ok(vec_to_numpy_f64(py, result))
}
// ============================================================================
// Helper Functions
// ============================================================================
// ============================================================================
// Monte Carlo Forward Simulation
// ============================================================================
/// Run Monte Carlo forward simulation for a portfolio.
///
/// Uses Geometric Brownian Motion with Cholesky-decomposed correlated random
/// draws, parallelized via Rayon.
///
/// # Arguments
/// * `returns` - List of per-strategy return arrays (N strategies)
/// * `weights` - Portfolio weight vector (length N, sums to 1)
/// * `correlation_matrix` - N x N correlation matrix (flattened row-major as 2D list)
/// * `initial_value` - Starting portfolio value
/// * `n_simulations` - Number of simulation paths (default: 10000)
/// * `horizon_days` - Forward simulation horizon in trading days (default: 252)
/// * `seed` - Random seed for reproducibility (default: 42)
#[pyfunction]
#[pyo3(signature = (returns, weights, correlation_matrix, initial_value, n_simulations=10000, horizon_days=252, seed=42))]
pub fn simulate_portfolio_mc(
py: Python<'_>,
returns: Vec<PyReadonlyArray1<'_, f64>>,
weights: PyReadonlyArray1<'_, f64>,
correlation_matrix: Vec<PyReadonlyArray1<'_, f64>>,
initial_value: f64,
n_simulations: usize,
horizon_days: usize,
seed: u64,
) -> PyResult<PyObject> {
use crate::portfolio::monte_carlo::{simulate_portfolio_forward, MonteCarloConfig};
// Convert numpy arrays to Rust vecs
let rust_returns: Vec<Vec<f64>> =
returns.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect();
let rust_weights: Vec<f64> = weights.as_slice().unwrap().to_vec();
let rust_corr: Vec<Vec<f64>> =
correlation_matrix.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect();
let config = MonteCarloConfig { n_simulations, horizon_days, seed };
// Run simulation (releases GIL for Rayon parallelism)
let result = py.allow_threads(|| {
simulate_portfolio_forward(&rust_returns, &rust_weights, &rust_corr, initial_value, &config)
});
// Build Python dict result
let dict = pyo3::types::PyDict::new(py);
// percentile_paths: list of (percentile, list[float])
let paths_list = pyo3::types::PyList::empty(py);
for (pct, path) in &result.percentile_paths {
let path_list = pyo3::types::PyList::new(py, path);
let tuple = pyo3::types::PyTuple::new(py, &[pct.to_object(py), path_list.to_object(py)]);
paths_list.append(tuple)?;
}
dict.set_item("percentile_paths", paths_list)?;
// final_values as numpy array for efficiency
let final_arr = PyArray1::from_vec(py, result.final_values);
dict.set_item("final_values", final_arr)?;
dict.set_item("expected_return", result.expected_return)?;
dict.set_item("probability_of_loss", result.probability_of_loss)?;
dict.set_item("var_95", result.var_95)?;
dict.set_item("cvar_95", result.cvar_95)?;
Ok(dict.into())
}
/// Convert Rust BacktestResult to Python PyBacktestResult.
fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult {
let metrics = PyBacktestMetrics {
@@ -941,6 +1347,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
+60 -5
View File
@@ -2,8 +2,11 @@
//!
//! Supports multiple instruments with synchronized signals.
use std::collections::HashMap;
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, InstrumentConfig,
OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
@@ -74,6 +77,22 @@ impl BasketBacktest {
/// # Returns
/// Combined backtest result
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
self.run_with_instrument_configs(instruments, None)
}
/// Run basket backtest with optional per-instrument configurations.
///
/// # Arguments
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
/// * `instrument_configs` - Optional map of symbol -> InstrumentConfig
///
/// # Returns
/// Combined backtest result
pub fn run_with_instrument_configs(
&self,
instruments: &[(OhlcvData, CompiledSignals)],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> BacktestResult {
if instruments.is_empty() {
return self.empty_result();
}
@@ -168,7 +187,15 @@ impl BasketBacktest {
// Calculate position sizes
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect();
let sizes = self.calculate_sizes(&prices, &weights, cash);
let symbols: Vec<&str> =
instruments.iter().map(|(_, s)| s.symbol.as_str()).collect();
let sizes = self.calculate_sizes_with_configs(
&prices,
&weights,
cash,
&symbols,
instrument_configs,
);
// Enter positions
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
@@ -249,7 +276,21 @@ impl BasketBacktest {
}
/// Calculate position sizes for each instrument.
#[allow(dead_code)]
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
let symbols: Vec<&str> = vec![""; prices.len()];
self.calculate_sizes_with_configs(prices, weights, available_capital, &symbols, None)
}
/// Calculate position sizes with optional per-instrument config (lot_size rounding, capital caps).
fn calculate_sizes_with_configs(
&self,
prices: &[f64],
weights: &[f64],
available_capital: f64,
symbols: &[&str],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> Vec<f64> {
let n = prices.len();
let total_weight: f64 = weights.iter().sum();
@@ -260,12 +301,26 @@ impl BasketBacktest {
prices
.iter()
.zip(weights.iter())
.map(|(&price, &weight)| {
.enumerate()
.map(|(idx, (&price, &weight))| {
if price <= 0.0 {
return 0.0;
}
let allocation = available_capital * (weight / total_weight);
allocation / price
let default_allocation = available_capital * (weight / total_weight);
// Use per-instrument alloted_capital if set, capped at default allocation
let inst_config = instrument_configs
.and_then(|configs| symbols.get(idx).and_then(|sym| configs.get(*sym)));
let allocation = inst_config
.and_then(|ic| ic.alloted_capital)
.map(|cap| cap.min(default_allocation))
.unwrap_or(default_allocation);
let raw_size = allocation / price;
// Round to lot_size
inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size)
})
.collect()
}
+4
View File
@@ -5,9 +5,13 @@ pub mod multi;
pub mod options;
pub mod pairs;
pub mod single;
pub mod spreads;
pub use basket::BasketBacktest;
pub use multi::MultiStrategyBacktest;
pub use options::OptionsBacktest;
pub use pairs::PairsBacktest;
pub use single::SingleBacktest;
pub use spreads::{
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
};
+21 -1
View File
@@ -1,6 +1,8 @@
//! Single instrument backtest implementation.
use crate::core::types::{BacktestConfig, BacktestResult, CompiledSignals, OhlcvData};
use crate::core::types::{
BacktestConfig, BacktestResult, CompiledSignals, InstrumentConfig, OhlcvData,
};
use crate::portfolio::engine::PortfolioEngine;
/// Single instrument backtest runner.
@@ -28,6 +30,24 @@ impl SingleBacktest {
self.engine.run_single(ohlcv, signals)
}
/// Run the backtest with per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
self.engine.run_single_with_instrument_config(ohlcv, signals, inst_config)
}
/// Run backtest from raw arrays.
///
/// # Arguments
+585
View File
@@ -0,0 +1,585 @@
//! Multi-leg options spread backtesting implementation.
//!
//! Provides high-performance spread backtesting for:
//! - Straddles and Strangles
//! - Vertical spreads (bull/bear call/put)
//! - Iron Condors and Iron Butterflies
//! - Calendar and Diagonal spreads
//!
//! Key features:
//! - Single-pass O(n) algorithm
//! - Coordinated entry/exit across all legs
//! - Net premium P&L calculation
//! - Combined Greeks tracking
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, Direction, ExitReason, Trade,
};
use crate::metrics::streaming::StreamingMetrics;
use serde::{Deserialize, Serialize};
/// Spread type enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpreadType {
Straddle,
Strangle,
VerticalCall,
VerticalPut,
IronCondor,
IronButterfly,
ButterflyCall,
ButterflyPut,
Calendar,
Diagonal,
Custom,
}
/// Option type for a leg.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OptionType {
Call,
Put,
}
impl OptionType {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"CE" | "CALL" | "C" => Some(OptionType::Call),
"PE" | "PUT" | "P" => Some(OptionType::Put),
_ => None,
}
}
}
/// Configuration for a single leg of a spread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegConfig {
/// Option type (Call or Put).
pub option_type: OptionType,
/// Strike price.
pub strike: f64,
/// Position quantity (+1 long, -1 short).
pub quantity: i32,
/// Lot size for the option.
pub lot_size: usize,
}
impl LegConfig {
pub fn new(option_type: OptionType, strike: f64, quantity: i32, lot_size: usize) -> Self {
Self { option_type, strike, quantity, lot_size }
}
/// Check if this is a long position.
pub fn is_long(&self) -> bool {
self.quantity > 0
}
/// Check if this is a short position.
pub fn is_short(&self) -> bool {
self.quantity < 0
}
}
/// Configuration for spread backtest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpreadConfig {
/// Base backtest configuration.
pub base: BacktestConfig,
/// Spread type.
pub spread_type: SpreadType,
/// Leg configurations.
pub leg_configs: Vec<LegConfig>,
/// Maximum loss threshold (optional, for early exit).
pub max_loss: Option<f64>,
/// Target profit threshold (optional, for early exit).
pub target_profit: Option<f64>,
/// Whether to close at end of day.
pub close_at_eod: bool,
}
impl Default for SpreadConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
spread_type: SpreadType::Custom,
leg_configs: Vec::new(),
max_loss: None,
target_profit: None,
close_at_eod: false,
}
}
}
/// State for a single leg position.
#[derive(Debug, Clone)]
struct LegPosition {
/// Entry premium price.
pub entry_premium: f64,
/// Entry index.
#[allow(dead_code)]
pub entry_idx: usize,
/// Current premium price.
pub current_premium: f64,
/// Leg configuration.
pub config: LegConfig,
}
impl LegPosition {
fn new(config: LegConfig, entry_premium: f64, entry_idx: usize) -> Self {
Self { entry_premium, entry_idx, current_premium: entry_premium, config }
}
/// Calculate unrealized P&L for this leg.
fn unrealized_pnl(&self) -> f64 {
// For short positions: profit when premium decreases
// For long positions: profit when premium increases
let premium_change = self.current_premium - self.entry_premium;
let quantity = self.config.quantity as f64;
let lot_size = self.config.lot_size as f64;
-quantity * premium_change * lot_size
}
}
/// Spread position state.
#[derive(Debug, Clone)]
struct SpreadPosition {
/// Individual leg positions.
pub legs: Vec<LegPosition>,
/// Entry bar index.
pub entry_idx: usize,
/// Entry net premium (positive = credit, negative = debit).
pub entry_net_premium: f64,
/// Entry timestamp.
pub entry_time: i64,
/// Whether position is open.
pub is_open: bool,
}
impl SpreadPosition {
fn new(legs: Vec<LegPosition>, entry_idx: usize, entry_time: i64) -> Self {
let entry_net_premium: f64 = legs
.iter()
.map(|leg| leg.entry_premium * leg.config.quantity as f64 * leg.config.lot_size as f64)
.sum();
Self { legs, entry_idx, entry_net_premium, entry_time, is_open: true }
}
/// Calculate total unrealized P&L across all legs.
fn total_unrealized_pnl(&self) -> f64 {
self.legs.iter().map(|leg| leg.unrealized_pnl()).sum()
}
/// Update leg premiums.
fn update_premiums(&mut self, leg_premiums: &[f64]) {
for (leg, &premium) in self.legs.iter_mut().zip(leg_premiums.iter()) {
leg.current_premium = premium;
}
}
/// Close the position and return P&L.
fn close(&mut self) -> f64 {
self.is_open = false;
self.total_unrealized_pnl()
}
}
/// Spread backtest runner.
pub struct SpreadBacktest {
config: SpreadConfig,
}
impl SpreadBacktest {
/// Create a new spread backtest.
pub fn new(config: SpreadConfig) -> Self {
Self { config }
}
/// Run the spread backtest.
///
/// # Arguments
/// * `timestamps` - Timestamp array
/// * `underlying_close` - Underlying close prices
/// * `legs_premiums` - Premium series for each leg (Vec of Vec)
/// * `entries` - Entry signals
/// * `exits` - Exit signals
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run(
&self,
timestamps: &[i64],
_underlying_close: &[f64],
legs_premiums: &[Vec<f64>],
entries: &[bool],
exits: &[bool],
) -> BacktestResult {
let n = timestamps.len();
// Validate inputs
if legs_premiums.len() != self.config.leg_configs.len() {
return self.empty_result(n);
}
for premiums in legs_premiums {
if premiums.len() != n {
return self.empty_result(n);
}
}
let mut metrics = StreamingMetrics::with_initial_capital(self.config.base.initial_capital);
let mut equity_curve = Vec::with_capacity(n);
let mut drawdown_curve = Vec::with_capacity(n);
let mut returns = Vec::with_capacity(n);
let mut trades: Vec<Trade> = Vec::new();
let mut trade_id: u64 = 0;
let mut cash = self.config.base.initial_capital;
let mut position: Option<SpreadPosition> = None;
let mut prev_equity = cash;
// Single-pass O(n) algorithm
for i in 0..n {
// Get current leg premiums
let current_premiums: Vec<f64> = legs_premiums.iter().map(|p| p[i]).collect();
// Update position premiums if open
if let Some(ref mut pos) = position {
pos.update_premiums(&current_premiums);
}
// Calculate unrealized P&L for exit checks
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
// Check for exit signals or conditions
let should_exit = position.is_some()
&& (exits[i]
|| self.check_max_loss(&position, unrealized_pnl)
|| self.check_target_profit(&position, unrealized_pnl));
if should_exit {
if let Some(mut pos) = position.take() {
let pnl = pos.close();
let fees = self.calculate_fees(&pos);
let net_pnl = pnl - fees;
cash += net_pnl;
// Record trade
trade_id += 1;
let exit_reason = if exits[i] {
ExitReason::Signal
} else if self.check_max_loss(&Some(pos.clone()), pnl) {
ExitReason::StopLoss
} else {
ExitReason::TakeProfit
};
let entry_premium = pos.entry_net_premium;
let exit_premium: f64 = current_premiums
.iter()
.zip(self.config.leg_configs.iter())
.map(|(&p, cfg)| p * cfg.quantity as f64 * cfg.lot_size as f64)
.sum();
trades.push(Trade {
id: trade_id,
symbol: "SPREAD".to_string(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: entry_premium,
exit_price: exit_premium,
size: 1.0,
direction: Direction::Long, // Spreads are treated as "long spread"
pnl: net_pnl,
return_pct: if entry_premium.abs() > 0.0 {
net_pnl / entry_premium.abs() * 100.0
} else {
0.0
},
entry_time: pos.entry_time,
exit_time: timestamps[i],
fees,
exit_reason,
});
metrics.record_trade(
net_pnl,
net_pnl / entry_premium.abs() * 100.0,
i - pos.entry_idx,
);
}
}
// Check for entry signals
if position.is_none() && entries[i] {
let legs: Vec<LegPosition> = self
.config
.leg_configs
.iter()
.zip(current_premiums.iter())
.map(|(cfg, &premium)| LegPosition::new(cfg.clone(), premium, i))
.collect();
let new_position = SpreadPosition::new(legs, i, timestamps[i]);
// Calculate entry fees
let entry_fees = self.calculate_entry_fees(&new_position);
cash -= entry_fees;
position = Some(new_position);
}
// Update equity tracking
let equity = cash + position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
equity_curve.push(equity);
let daily_return =
if prev_equity > 0.0 { (equity - prev_equity) / prev_equity } else { 0.0 };
returns.push(daily_return);
prev_equity = equity;
// Update drawdown
metrics.update_equity(equity);
drawdown_curve.push(metrics.current_drawdown_pct());
}
// Close any remaining open position at end
if let Some(mut pos) = position.take() {
let pnl = pos.close();
let fees = self.calculate_fees(&pos);
cash += pnl - fees;
}
// Finalize metrics
let final_metrics = metrics.finalize(self.config.base.initial_capital, cash, &returns);
BacktestResult { metrics: final_metrics, equity_curve, drawdown_curve, trades, returns }
}
/// Check if max loss threshold is hit.
fn check_max_loss(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
if let Some(max_loss) = self.config.max_loss {
if unrealized_pnl < -max_loss {
return true;
}
}
false
}
/// Check if target profit threshold is hit.
fn check_target_profit(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
if let Some(target) = self.config.target_profit {
if unrealized_pnl > target {
return true;
}
}
false
}
/// Calculate entry fees for a position.
fn calculate_entry_fees(&self, position: &SpreadPosition) -> f64 {
let total_premium: f64 = position
.legs
.iter()
.map(|leg| leg.entry_premium.abs() * leg.config.lot_size as f64)
.sum();
total_premium * self.config.base.fees
}
/// Calculate exit fees for a position.
fn calculate_fees(&self, position: &SpreadPosition) -> f64 {
let total_premium: f64 = position
.legs
.iter()
.map(|leg| leg.current_premium.abs() * leg.config.lot_size as f64)
.sum();
total_premium * self.config.base.fees * 2.0 // Entry + Exit
}
/// Create an empty result (used for validation failures).
fn empty_result(&self, n: usize) -> BacktestResult {
BacktestResult {
metrics: BacktestMetrics::default(),
equity_curve: vec![self.config.base.initial_capital; n],
drawdown_curve: vec![0.0; n],
trades: Vec::new(),
returns: vec![0.0; n],
}
}
}
/// Convenience function to create a straddle spread config.
pub fn create_straddle_config(
base: BacktestConfig,
strike: f64,
lot_size: usize,
short: bool,
) -> SpreadConfig {
let quantity = if short { -1 } else { 1 };
SpreadConfig {
base,
spread_type: SpreadType::Straddle,
leg_configs: vec![
LegConfig::new(OptionType::Call, strike, quantity, lot_size),
LegConfig::new(OptionType::Put, strike, quantity, lot_size),
],
..Default::default()
}
}
/// Convenience function to create a strangle spread config.
pub fn create_strangle_config(
base: BacktestConfig,
call_strike: f64,
put_strike: f64,
lot_size: usize,
short: bool,
) -> SpreadConfig {
let quantity = if short { -1 } else { 1 };
SpreadConfig {
base,
spread_type: SpreadType::Strangle,
leg_configs: vec![
LegConfig::new(OptionType::Call, call_strike, quantity, lot_size),
LegConfig::new(OptionType::Put, put_strike, quantity, lot_size),
],
..Default::default()
}
}
/// Convenience function to create an iron condor spread config.
pub fn create_iron_condor_config(
base: BacktestConfig,
short_put_strike: f64,
long_put_strike: f64,
short_call_strike: f64,
long_call_strike: f64,
lot_size: usize,
) -> SpreadConfig {
SpreadConfig {
base,
spread_type: SpreadType::IronCondor,
leg_configs: vec![
LegConfig::new(OptionType::Put, short_put_strike, -1, lot_size),
LegConfig::new(OptionType::Put, long_put_strike, 1, lot_size),
LegConfig::new(OptionType::Call, short_call_strike, -1, lot_size),
LegConfig::new(OptionType::Call, long_call_strike, 1, lot_size),
],
..Default::default()
}
}
/// Convenience function to create a vertical spread config.
pub fn create_vertical_spread_config(
base: BacktestConfig,
option_type: OptionType,
long_strike: f64,
short_strike: f64,
lot_size: usize,
) -> SpreadConfig {
let spread_type = match option_type {
OptionType::Call => SpreadType::VerticalCall,
OptionType::Put => SpreadType::VerticalPut,
};
SpreadConfig {
base,
spread_type,
leg_configs: vec![
LegConfig::new(option_type, long_strike, 1, lot_size),
LegConfig::new(option_type, short_strike, -1, lot_size),
],
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::StopConfig;
use crate::core::types::TargetConfig;
fn sample_data() -> (Vec<i64>, Vec<f64>, Vec<Vec<f64>>, Vec<bool>, Vec<bool>) {
let n = 20;
let timestamps: Vec<i64> = (0..n as i64).collect();
let underlying: Vec<f64> = (100..120).map(|x| x as f64).collect();
// Call and Put premiums
let call_premiums: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect();
let put_premiums: Vec<f64> = (0..n).map(|i| 5.0 - (i as f64 * 0.1)).collect();
let legs_premiums = vec![call_premiums, put_premiums];
let entries = vec![
false, true, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false,
];
let exits = vec![
false, false, false, false, false, false, false, false, false, true, false, false,
false, false, false, false, false, false, false, false,
];
(timestamps, underlying, legs_premiums, entries, exits)
}
#[test]
fn test_straddle_backtest() {
let base_config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.001,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let config = create_straddle_config(base_config, 100.0, 50, true);
let backtest = SpreadBacktest::new(config);
let (timestamps, underlying, legs_premiums, entries, exits) = sample_data();
let result = backtest.run(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
assert!(result.equity_curve.len() == timestamps.len());
}
#[test]
fn test_iron_condor_backtest() {
let base_config = BacktestConfig::default();
let config = create_iron_condor_config(
base_config,
95.0, // short put
90.0, // long put
105.0, // short call
110.0, // long call
50,
);
let backtest = SpreadBacktest::new(config);
let n = 20;
let timestamps: Vec<i64> = (0..n as i64).collect();
let underlying: Vec<f64> = vec![100.0; n];
// Four legs: short put, long put, short call, long call
let legs_premiums = vec![
vec![3.0; n], // short put
vec![1.5; n], // long put
vec![3.0; n], // short call
vec![1.5; n], // long call
];
let mut entries = vec![false; n];
entries[1] = true;
let mut exits = vec![false; n];
exits[15] = true;
let result = backtest.run(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
}
}
Generated
+1 -1
View File
@@ -4,5 +4,5 @@ requires-python = ">=3.10"
[[package]]
name = "raptorbt"
version = "0.1.0"
version = "0.2.0"
source = { editable = "." }