Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e91293e1a | |||
| c9451069d8 | |||
| 997e234a85 | |||
| e049a2f968 | |||
| f7592d5d79 | |||
| 87545683eb | |||
| eb5335e809 | |||
| 0ff67e7fe2 | |||
| ab568cc9fb | |||
| 4d4ea2e5e9 | |||
| a82ddc598a | |||
| bb6ce05c57 | |||
| aad09da117 | |||
| c711e9ce8e |
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.2.1"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "raptorbt"
|
||||
version = "0.2.1"
|
||||
version = "0.3.4"
|
||||
edition = "2021"
|
||||
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
|
||||
authors = ["Alphabench <contact@alphabench.in>"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/alphabench/raptorbt"
|
||||
homepage = "https://github.com/alphabench/raptorbt"
|
||||
homepage = "https://www.alphabench.in/raptorbt"
|
||||
readme = "README.md"
|
||||
keywords = ["backtesting", "trading", "quantitative-finance", "rust", "python"]
|
||||
categories = ["finance", "simulation"]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# RaptorBT
|
||||
|
||||
[](https://pypi.org/project/raptorbt/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pypi.org/project/raptorbt/)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://pepy.tech/projects/raptorbt)
|
||||
|
||||
**Blazing-fast backtesting for the modern quant.**
|
||||
|
||||
@@ -78,9 +79,11 @@ RaptorBT was built to address the performance limitations of VectorBT. Benchmark
|
||||
|
||||
### 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
|
||||
@@ -126,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
|
||||
@@ -133,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
|
||||
@@ -140,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
|
||||
@@ -157,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
|
||||
@@ -264,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,
|
||||
@@ -273,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
|
||||
)
|
||||
```
|
||||
|
||||
@@ -287,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
|
||||
)
|
||||
```
|
||||
|
||||
@@ -381,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
|
||||
@@ -516,6 +584,68 @@ config.set_risk_reward_target(ratio=2.0) # 2:1 risk-reward ratio
|
||||
|
||||
---
|
||||
|
||||
## Monte Carlo Portfolio Simulation
|
||||
|
||||
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 numpy as np
|
||||
import raptorbt
|
||||
|
||||
# Historical daily returns per strategy/asset (numpy arrays)
|
||||
returns = [
|
||||
np.array([0.001, -0.002, 0.003, ...]), # Strategy 1 returns
|
||||
np.array([0.002, 0.001, -0.001, ...]), # Strategy 2 returns
|
||||
]
|
||||
|
||||
# Portfolio weights (must sum to 1.0)
|
||||
weights = np.array([0.6, 0.4])
|
||||
|
||||
# Correlation matrix (N x N)
|
||||
correlation_matrix = [
|
||||
np.array([1.0, 0.3]),
|
||||
np.array([0.3, 1.0]),
|
||||
]
|
||||
|
||||
# Run simulation
|
||||
result = raptorbt.simulate_portfolio_mc(
|
||||
returns=returns,
|
||||
weights=weights,
|
||||
correlation_matrix=correlation_matrix,
|
||||
initial_value=100000.0,
|
||||
n_simulations=10000, # Number of Monte Carlo paths (default: 10,000)
|
||||
horizon_days=252, # Forward projection horizon (default: 252)
|
||||
seed=42, # Random seed for reproducibility (default: 42)
|
||||
)
|
||||
|
||||
# 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 Comparison
|
||||
|
||||
RaptorBT is designed as a drop-in replacement for VectorBT. Here's a side-by-side comparison:
|
||||
@@ -573,18 +703,18 @@ print(f"Max Drawdown: {result.metrics.max_drawdown_pct}%")
|
||||
|
||||
### Metric Mapping
|
||||
|
||||
| VectorBT Key | RaptorBT Attribute |
|
||||
| ---------------------- | ------------------------------ |
|
||||
| `Total Return [%]` | `metrics.total_return_pct` |
|
||||
| `Sharpe Ratio` | `metrics.sharpe_ratio` |
|
||||
| `Sortino Ratio` | `metrics.sortino_ratio` |
|
||||
| `Max Drawdown [%]` | `metrics.max_drawdown_pct` |
|
||||
| `Win Rate [%]` | `metrics.win_rate_pct` |
|
||||
| `Profit Factor` | `metrics.profit_factor` |
|
||||
| `SQN` | `metrics.sqn` |
|
||||
| `Omega Ratio` | `metrics.omega_ratio` |
|
||||
| `Total Trades` | `metrics.total_trades` |
|
||||
| `Expectancy` | `metrics.expectancy` |
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -611,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
|
||||
@@ -663,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()
|
||||
@@ -683,7 +883,7 @@ for trade in result.trades():
|
||||
print(trade.pnl) # Profit/Loss
|
||||
print(trade.return_pct) # Return percentage
|
||||
print(trade.fees) # Fees paid
|
||||
print(trade.exit_reason) # "Signal", "StopLoss", "TakeProfit"
|
||||
print(trade.exit_reason) # "Signal", "StopLoss", "TakeProfit", "TrailingStop", "EndOfData", "Settlement"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -797,6 +997,65 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.3.4
|
||||
|
||||
- Add single-leg option spread types: `LongCall`, `LongPut`, `NakedCall`, `NakedPut` to `SpreadType` enum
|
||||
- Add `ExitReason::Settlement` for option expiry settlement exits
|
||||
- Add `leg_expiry_timestamps` parameter to `run_spread_backtest` for per-leg expiry tracking
|
||||
- Positions are force-closed at settlement when any leg expires, with premiums replaced by intrinsic value
|
||||
- Prevent re-entry after all legs have expired
|
||||
|
||||
### v0.3.3
|
||||
|
||||
- Add `batch_spread_backtest` function for running multiple spread backtests in parallel via Rayon
|
||||
- Add `PyBatchSpreadItem` class for defining individual items in a batch spread backtest
|
||||
- Shared data (timestamps, underlying close) is converted once and reused across all items
|
||||
- GIL released during parallel execution for maximum Python concurrency
|
||||
- Each item carries its own `strategy_id`, leg configs, signals, spread type, and optional max loss / target profit
|
||||
- Returns a list of `(strategy_id, PyBacktestResult)` tuples preserving result-to-input mapping
|
||||
|
||||
### v0.3.2
|
||||
|
||||
- Add `payoff_ratio` metric to `BacktestMetrics` — average winning trade return divided by average losing trade return (absolute), measures risk/reward per trade
|
||||
- Add `recovery_factor` metric to `BacktestMetrics` — net profit divided by maximum drawdown in absolute terms, measures how many times over the strategy recovered from its worst drawdown
|
||||
- Both metrics computed in `StreamingMetrics::finalize()` (single-instrument backtest) and `PortfolioEngine` (multi-strategy aggregation)
|
||||
- Both metrics exposed via PyO3 as `#[pyo3(get)]` attributes on `PyBacktestMetrics`
|
||||
- Handles edge cases: returns `f64::INFINITY` when denominator is zero with positive numerator, `0.0` otherwise
|
||||
|
||||
### v0.3.1
|
||||
|
||||
- Add Monte Carlo portfolio simulation (`simulate_portfolio_mc`) for forward risk projection
|
||||
- Geometric Brownian Motion (GBM) with Cholesky decomposition for correlated multi-asset simulation
|
||||
- Rayon-parallelized simulation paths with deterministic seeding (xoshiro256\*\*)
|
||||
- Returns percentile paths (P5/P25/P50/P75/P95), VaR, CVaR, expected return, and probability of loss
|
||||
- GIL released during simulation for maximum Python concurrency
|
||||
|
||||
### v0.3.0
|
||||
|
||||
- Per-instrument configuration via `PyInstrumentConfig` (lot_size, alloted_capital, stop/target overrides)
|
||||
- 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
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "raptorbt"
|
||||
version = "0.2.1"
|
||||
version = "0.3.4"
|
||||
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"
|
||||
@@ -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]
|
||||
|
||||
@@ -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.2.1"
|
||||
__version__ = "0.3.4"
|
||||
|
||||
__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.
@@ -212,6 +212,8 @@ pub enum ExitReason {
|
||||
TrailingStop,
|
||||
/// End of data.
|
||||
EndOfData,
|
||||
/// Option expiry settlement.
|
||||
Settlement,
|
||||
}
|
||||
|
||||
/// Backtest configuration.
|
||||
@@ -244,6 +246,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 +378,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 +507,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()?;
|
||||
|
||||
@@ -43,6 +44,13 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
||||
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)?)?;
|
||||
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
|
||||
|
||||
@@ -513,6 +513,30 @@ impl StreamingMetrics {
|
||||
let worst_trade_pct =
|
||||
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
|
||||
|
||||
// Payoff ratio: average win / average loss (absolute value)
|
||||
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
|
||||
avg_win_pct / avg_loss_pct.abs()
|
||||
} else if avg_win_pct > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Recovery factor: net profit / max drawdown (absolute value)
|
||||
let net_profit = final_value - initial_capital;
|
||||
let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 {
|
||||
let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital;
|
||||
if max_dd_absolute > 0.0 {
|
||||
net_profit / max_dd_absolute
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else if net_profit > 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
BacktestMetrics {
|
||||
total_return_pct,
|
||||
sharpe_ratio,
|
||||
@@ -545,6 +569,8 @@ impl StreamingMetrics {
|
||||
max_consecutive_losses: self.max_consecutive_losses,
|
||||
avg_holding_period,
|
||||
exposure_pct: 0.0, // TODO: calculate based on time in market
|
||||
payoff_ratio,
|
||||
recovery_factor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+98
-16
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+334
-6
@@ -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;
|
||||
@@ -100,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)]
|
||||
@@ -329,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]
|
||||
@@ -419,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>,
|
||||
@@ -435,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),
|
||||
@@ -457,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<(
|
||||
@@ -484,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()
|
||||
@@ -521,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))
|
||||
}
|
||||
@@ -678,7 +782,7 @@ pub fn run_pairs_backtest<'py>(
|
||||
|
||||
/// Run spread backtest (multi-leg options).
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None))]
|
||||
#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None, leg_expiry_timestamps=None))]
|
||||
pub fn run_spread_backtest<'py>(
|
||||
_py: Python<'py>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
@@ -691,6 +795,7 @@ pub fn run_spread_backtest<'py>(
|
||||
spread_type: &str,
|
||||
max_loss: Option<f64>,
|
||||
target_profit: Option<f64>,
|
||||
leg_expiry_timestamps: Option<Vec<i64>>,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ts = numpy_to_vec_i64(timestamps);
|
||||
let underlying = numpy_to_vec_f64(underlying_close);
|
||||
@@ -720,6 +825,10 @@ pub fn run_spread_backtest<'py>(
|
||||
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||
"calendar" => SpreadType::Calendar,
|
||||
"diagonal" => SpreadType::Diagonal,
|
||||
"long_call" | "longcall" => SpreadType::LongCall,
|
||||
"long_put" | "longput" => SpreadType::LongPut,
|
||||
"naked_call" | "nakedcall" => SpreadType::NakedCall,
|
||||
"naked_put" | "nakedput" => SpreadType::NakedPut,
|
||||
_ => SpreadType::Custom,
|
||||
};
|
||||
|
||||
@@ -730,6 +839,7 @@ pub fn run_spread_backtest<'py>(
|
||||
max_loss,
|
||||
target_profit,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps,
|
||||
};
|
||||
|
||||
let backtest = SpreadBacktest::new(spread_config);
|
||||
@@ -738,6 +848,151 @@ pub fn run_spread_backtest<'py>(
|
||||
Ok(convert_result(result))
|
||||
}
|
||||
|
||||
/// A single spread backtest item for batch execution.
|
||||
#[pyclass]
|
||||
#[derive(Clone)]
|
||||
pub struct PyBatchSpreadItem {
|
||||
#[pyo3(get, set)]
|
||||
pub strategy_id: String,
|
||||
pub legs_premiums: Vec<Vec<f64>>,
|
||||
pub leg_configs: Vec<(String, f64, i32, usize)>,
|
||||
pub entries: Vec<bool>,
|
||||
pub exits: Vec<bool>,
|
||||
#[pyo3(get, set)]
|
||||
pub spread_type: String,
|
||||
#[pyo3(get, set)]
|
||||
pub max_loss: Option<f64>,
|
||||
#[pyo3(get, set)]
|
||||
pub target_profit: Option<f64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBatchSpreadItem {
|
||||
#[new]
|
||||
#[pyo3(signature = (strategy_id, legs_premiums, leg_configs, entries, exits,
|
||||
spread_type="custom", max_loss=None, target_profit=None))]
|
||||
fn new(
|
||||
strategy_id: String,
|
||||
legs_premiums: Vec<PyReadonlyArray1<f64>>,
|
||||
leg_configs: Vec<(String, f64, i32, usize)>,
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
spread_type: &str,
|
||||
max_loss: Option<f64>,
|
||||
target_profit: Option<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
strategy_id,
|
||||
legs_premiums: legs_premiums.into_iter().map(numpy_to_vec_f64).collect(),
|
||||
leg_configs,
|
||||
entries: numpy_to_vec_bool(entries),
|
||||
exits: numpy_to_vec_bool(exits),
|
||||
spread_type: spread_type.to_string(),
|
||||
max_loss,
|
||||
target_profit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multiple spread backtests in parallel via Rayon.
|
||||
///
|
||||
/// Shared data (timestamps, underlying_close) is converted once, then each
|
||||
/// item is backtested on its own Rayon thread with the GIL released.
|
||||
///
|
||||
/// Returns a Vec of (strategy_id, PyBacktestResult) tuples.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, underlying_close, items, config=None))]
|
||||
pub fn batch_spread_backtest(
|
||||
py: Python<'_>,
|
||||
timestamps: PyReadonlyArray1<i64>,
|
||||
underlying_close: PyReadonlyArray1<f64>,
|
||||
items: Vec<PyBatchSpreadItem>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
) -> PyResult<Vec<(String, PyBacktestResult)>> {
|
||||
use rayon::prelude::*;
|
||||
|
||||
// Convert shared data while holding GIL
|
||||
let ts = numpy_to_vec_i64(timestamps);
|
||||
let underlying = numpy_to_vec_f64(underlying_close);
|
||||
let base_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
|
||||
|
||||
// Prepare each item into a self-contained struct for parallel execution
|
||||
struct PreparedItem {
|
||||
strategy_id: String,
|
||||
premiums: Vec<Vec<f64>>,
|
||||
entries: Vec<bool>,
|
||||
exits: Vec<bool>,
|
||||
spread_config: SpreadConfig,
|
||||
}
|
||||
|
||||
let prepared: Vec<PreparedItem> = items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let rust_leg_configs: Vec<LegConfig> = item
|
||||
.leg_configs
|
||||
.into_iter()
|
||||
.map(|(opt_type, strike, quantity, lot_size)| {
|
||||
let option_type =
|
||||
SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call);
|
||||
LegConfig::new(option_type, strike, quantity, lot_size)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let spread_type_enum = match item.spread_type.to_lowercase().as_str() {
|
||||
"straddle" => SpreadType::Straddle,
|
||||
"strangle" => SpreadType::Strangle,
|
||||
"vertical_call" | "verticalcall" => SpreadType::VerticalCall,
|
||||
"vertical_put" | "verticalput" => SpreadType::VerticalPut,
|
||||
"iron_condor" | "ironcondor" => SpreadType::IronCondor,
|
||||
"iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly,
|
||||
"butterfly_call" | "butterflycall" => SpreadType::ButterflyCall,
|
||||
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||
"calendar" => SpreadType::Calendar,
|
||||
"diagonal" => SpreadType::Diagonal,
|
||||
"long_call" | "longcall" => SpreadType::LongCall,
|
||||
"long_put" | "longput" => SpreadType::LongPut,
|
||||
"naked_call" | "nakedcall" => SpreadType::NakedCall,
|
||||
"naked_put" | "nakedput" => SpreadType::NakedPut,
|
||||
_ => SpreadType::Custom,
|
||||
};
|
||||
|
||||
let spread_config = SpreadConfig {
|
||||
base: base_config.clone(),
|
||||
spread_type: spread_type_enum,
|
||||
leg_configs: rust_leg_configs.clone(),
|
||||
max_loss: item.max_loss,
|
||||
target_profit: item.target_profit,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps: None,
|
||||
};
|
||||
|
||||
PreparedItem {
|
||||
strategy_id: item.strategy_id,
|
||||
premiums: item.legs_premiums,
|
||||
entries: item.entries,
|
||||
exits: item.exits,
|
||||
spread_config,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Release GIL and run all backtests in parallel via Rayon
|
||||
let results: Vec<(String, crate::core::types::BacktestResult)> = py.allow_threads(|| {
|
||||
prepared
|
||||
.into_par_iter()
|
||||
.map(|item| {
|
||||
let backtest = SpreadBacktest::new(item.spread_config);
|
||||
let result =
|
||||
backtest.run(&ts, &underlying, &item.premiums, &item.entries, &item.exits);
|
||||
(item.strategy_id, result)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Re-acquire GIL and convert results to Python objects
|
||||
Ok(results.into_iter().map(|(id, result)| (id, convert_result(result))).collect())
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))]
|
||||
@@ -998,6 +1253,77 @@ pub fn rolling_max<'py>(
|
||||
// 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 {
|
||||
@@ -1032,6 +1358,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,10 @@ pub enum SpreadType {
|
||||
ButterflyPut,
|
||||
Calendar,
|
||||
Diagonal,
|
||||
LongCall,
|
||||
LongPut,
|
||||
NakedCall,
|
||||
NakedPut,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -95,6 +99,9 @@ pub struct SpreadConfig {
|
||||
pub target_profit: Option<f64>,
|
||||
/// Whether to close at end of day.
|
||||
pub close_at_eod: bool,
|
||||
/// Per-leg expiry timestamps in nanoseconds (optional, for settlement logic).
|
||||
/// When provided, positions are force-closed at or after the earliest leg expiry.
|
||||
pub leg_expiry_timestamps: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
impl Default for SpreadConfig {
|
||||
@@ -106,6 +113,7 @@ impl Default for SpreadConfig {
|
||||
max_loss: None,
|
||||
target_profit: None,
|
||||
close_at_eod: false,
|
||||
leg_expiry_timestamps: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,9 +259,16 @@ impl SpreadBacktest {
|
||||
// Calculate unrealized P&L for exit checks
|
||||
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
|
||||
|
||||
// Check if any leg has expired at this bar
|
||||
let is_expiry = position.is_some()
|
||||
&& self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
|
||||
expiries.iter().any(|&exp_ts| timestamps[i] >= exp_ts)
|
||||
});
|
||||
|
||||
// Check for exit signals or conditions
|
||||
let should_exit = position.is_some()
|
||||
&& (exits[i]
|
||||
|| is_expiry
|
||||
|| self.check_max_loss(&position, unrealized_pnl)
|
||||
|| self.check_target_profit(&position, unrealized_pnl));
|
||||
|
||||
@@ -267,7 +282,9 @@ impl SpreadBacktest {
|
||||
|
||||
// Record trade
|
||||
trade_id += 1;
|
||||
let exit_reason = if exits[i] {
|
||||
let exit_reason = if is_expiry {
|
||||
ExitReason::Settlement
|
||||
} else if exits[i] {
|
||||
ExitReason::Signal
|
||||
} else if self.check_max_loss(&Some(pos.clone()), pnl) {
|
||||
ExitReason::StopLoss
|
||||
@@ -311,8 +328,12 @@ impl SpreadBacktest {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for entry signals
|
||||
if position.is_none() && entries[i] {
|
||||
// Check for entry signals (don't re-enter after all legs expired)
|
||||
let all_expired =
|
||||
self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
|
||||
expiries.iter().all(|&exp_ts| timestamps[i] >= exp_ts)
|
||||
});
|
||||
if position.is_none() && entries[i] && !all_expired {
|
||||
let legs: Vec<LegPosition> = self
|
||||
.config
|
||||
.leg_configs
|
||||
|
||||
Reference in New Issue
Block a user