Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0c756203 | ||
|
|
3a9f7564ad | ||
|
|
fb3a2dda25 | ||
|
|
3420edee2b | ||
|
|
fa6959bb99 | ||
|
|
514c235f1c |
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.3.4"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"criterion",
|
"criterion",
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.3.4"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
|
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
|
||||||
authors = ["Alphabench <contact@alphabench.in>"]
|
authors = ["Alphabench <contact@alphabench.in>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/alphabench/raptorbt"
|
repository = "https://github.com/alphabench/raptorbt"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**Blazing-fast backtesting for the modern quant.**
|
**Blazing-fast backtesting for the modern quant.**
|
||||||
|
|
||||||
RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. It serves as a drop-in replacement for VectorBT — delivering **HFT-grade compute efficiency** with full metric parity.
|
RaptorBT is a high-performance backtesting engine written in Rust with Python bindings via PyO3. Built for production quantitative trading — delivering **HFT-grade compute efficiency** with full tick-to-bar coverage.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>5,800x faster</strong> · <strong>45x smaller</strong> · <strong>100% deterministic</strong>
|
<strong>5,800x faster</strong> · <strong>45x smaller</strong> · <strong>100% deterministic</strong>
|
||||||
@@ -58,7 +58,6 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team.
|
|||||||
- [Metrics](#metrics)
|
- [Metrics](#metrics)
|
||||||
- [Indicators](#indicators)
|
- [Indicators](#indicators)
|
||||||
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
|
- [Stop-Loss & Take-Profit](#stop-loss--take-profit)
|
||||||
- [VectorBT Comparison](#vectorbt-comparison)
|
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Building from Source](#building-from-source)
|
- [Building from Source](#building-from-source)
|
||||||
- [Testing](#testing)
|
- [Testing](#testing)
|
||||||
@@ -67,23 +66,24 @@ Developed and maintained by the [Alphabench](https://alphabench.in) team.
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
RaptorBT was built to address the performance limitations of VectorBT. Benchmarked by the Alphabench team:
|
RaptorBT is benchmarked by the Alphabench team on Apple Silicon M-series:
|
||||||
|
|
||||||
| Metric | VectorBT | RaptorBT | Improvement |
|
| Metric | RaptorBT |
|
||||||
| ----------------------------- | ------------------- | ------------ | ------------------------- |
|
| ----------------------------- | ------------ |
|
||||||
| **Disk Footprint** | ~450MB | <10MB | **45x smaller** |
|
| **Disk Footprint** | <10MB |
|
||||||
| **Startup Latency** | 200-600ms | <10ms | **20-60x faster** |
|
| **Startup Latency** | <10ms |
|
||||||
| **Backtest Speed (1K bars)** | 1460ms | 0.25ms | **5,800x faster** |
|
| **Backtest Speed (1K bars)** | 0.25ms |
|
||||||
| **Backtest Speed (50K bars)** | 43ms | 1.7ms | **25x faster** |
|
| **Backtest Speed (50K bars)** | 1.7ms |
|
||||||
| **Memory Usage** | High (JIT + pandas) | Low (native) | **Significant reduction** |
|
| **Memory Usage** | Low (native) |
|
||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **6 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, and multi-strategy
|
- **7 Strategy Types**: Single instrument, basket/collective, pairs trading, options, spreads, multi-strategy, and tick-level
|
||||||
|
- **Tick-Level Simulation**: Full tick resolution for intraday options momentum, scalping, and microstructure strategies
|
||||||
- **Batch Spread Backtesting**: Run multiple spread backtests in parallel via Rayon with GIL released
|
- **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
|
- **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
|
- **33 Metrics**: 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
|
- **Technical Indicators**: SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min/Max, and tick feature functions
|
||||||
- **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets
|
- **Stop/Target Management**: Fixed, ATR-based, and trailing stops with risk-reward targets
|
||||||
- **100% Deterministic**: No JIT compilation variance between runs
|
- **100% Deterministic**: No JIT compilation variance between runs
|
||||||
- **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations
|
- **Native Parallelism**: Rayon-based parallel processing with explicit SIMD optimizations
|
||||||
@@ -97,26 +97,23 @@ RaptorBT was built to address the performance limitations of VectorBT. Benchmark
|
|||||||
Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy:
|
Tested on Apple Silicon M-series with random walk price data and SMA crossover strategy:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┬────────────┬───────────┬──────────┐
|
┌─────────────┬───────────┐
|
||||||
│ Data Size │ VectorBT │ RaptorBT │ Speedup │
|
│ Data Size │ RaptorBT │
|
||||||
├─────────────┼────────────┼───────────┼──────────┤
|
├─────────────┼───────────┤
|
||||||
│ 1,000 bars │ 1,460 ms │ 0.25 ms │ 5,827x │
|
│ 1,000 bars │ 0.25 ms │
|
||||||
│ 5,000 bars │ 36 ms │ 0.24 ms │ 153x │
|
│ 5,000 bars │ 0.24 ms │
|
||||||
│ 10,000 bars │ 37 ms │ 0.46 ms │ 80x │
|
│ 10,000 bars │ 0.46 ms │
|
||||||
│ 50,000 bars │ 43 ms │ 1.68 ms │ 26x │
|
│ 50,000 bars │ 1.68 ms │
|
||||||
└─────────────┴────────────┴───────────┴──────────┘
|
└─────────────┴───────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note**: First VectorBT run includes Numba JIT compilation overhead. Subsequent runs are faster but still significantly slower than RaptorBT.
|
|
||||||
|
|
||||||
### Metric Accuracy
|
### Metric Accuracy
|
||||||
|
|
||||||
RaptorBT produces **identical results** to VectorBT:
|
RaptorBT produces deterministic, reproducible results across runs:
|
||||||
|
|
||||||
```
|
```
|
||||||
VectorBT Total Return: 7.2764%
|
RaptorBT Total Return: 7.2764% (seed=42, 500 bars, SMA crossover)
|
||||||
RaptorBT Total Return: 7.2764%
|
Difference between runs: 0.0000% ✓
|
||||||
Difference: 0.0000% ✓
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -449,6 +446,74 @@ for strategy_id, result in results:
|
|||||||
print(f"{strategy_id}: {result.metrics.total_return_pct:.2f}%")
|
print(f"{strategy_id}: {result.metrics.total_return_pct:.2f}%")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7. Tick-Level Backtest
|
||||||
|
|
||||||
|
Simulate intraday strategies at full tick resolution — no bar resampling, no intra-bar path approximation. Designed for options momentum, scalping, and any setup where the exact fill tick matters.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import raptorbt
|
||||||
|
|
||||||
|
# Raw tick arrays (one element per tick, same length N)
|
||||||
|
# buy_qty_delta / sell_qty_delta must be per-tick deltas, NOT Zerodha cumulative sums
|
||||||
|
result = raptorbt.run_tick_backtest(
|
||||||
|
timestamps=timestamps_ns, # int64 nanoseconds-since-epoch
|
||||||
|
ltp=ltp_arr, # last traded price
|
||||||
|
bid=bid_arr,
|
||||||
|
ask=ask_arr,
|
||||||
|
buy_qty_delta=buy_delta, # pre-converted from cumulative: np.diff(buy_cum).clip(0)
|
||||||
|
sell_qty_delta=sell_delta,
|
||||||
|
oi=oi_arr,
|
||||||
|
entries=entry_signals, # bool array — True where entry is allowed
|
||||||
|
exits=exit_signals, # bool array — True where position should exit
|
||||||
|
symbol="NIFTY26APR24600PE",
|
||||||
|
initial_capital=100_000.0,
|
||||||
|
fees=0.001,
|
||||||
|
slippage=0.0005,
|
||||||
|
stop_loss_pct=5.0,
|
||||||
|
take_profit_pct=10.0,
|
||||||
|
max_hold_seconds=1800, # 30-minute maximum hold
|
||||||
|
entry_cooldown_ticks=10, # minimum ticks between entries
|
||||||
|
max_trades=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"trades: {result.metrics.total_trades}")
|
||||||
|
print(f"profit_factor: {result.metrics.profit_factor:.2f}")
|
||||||
|
print(f"win_rate: {result.metrics.win_rate_pct:.1f}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tick Signal & Feature Helpers
|
||||||
|
|
||||||
|
Precompute entry/exit signal arrays and tick microstructure features before calling `run_tick_backtest`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Signal arrays
|
||||||
|
entries = raptorbt.compute_tick_entry_signals(
|
||||||
|
spread_pct=raptorbt.tick_spread_pct(bid, ask),
|
||||||
|
bsi_delta=raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum), # pass raw cumulative
|
||||||
|
return_1m=raptorbt.return_window(timestamps_ns, ltp, window_seconds=60.0),
|
||||||
|
spread_pct_max=3.0,
|
||||||
|
bsi_min=0.55, # minimum buy-side delta fraction
|
||||||
|
return_1m_min_abs=0.3, # minimum 1-min return % (abs)
|
||||||
|
return_direction=1, # +1 long, -1 short
|
||||||
|
cooldown_ticks=10,
|
||||||
|
)
|
||||||
|
exits = raptorbt.compute_tick_exit_signals(
|
||||||
|
timestamps_ns=timestamps_ns,
|
||||||
|
eod_exit_time_ns=eod_ns, # force exit at/after this timestamp; 0 = disabled
|
||||||
|
)
|
||||||
|
|
||||||
|
# Feature arrays (all return Vec<f64> of same length as input)
|
||||||
|
spread = raptorbt.tick_spread_pct(bid, ask) # (ask-bid)/mid * 100
|
||||||
|
bsi = raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum) # delta BSI per tick
|
||||||
|
ret_1m = raptorbt.return_window(ts_ns, ltp, 60.0) # 1-min lookback return %
|
||||||
|
vol = raptorbt.realized_vol_rolling(ts_ns, ltp, 300.0) # 5-min realized vol %
|
||||||
|
oi_pos = raptorbt.oi_position_pct(oi, oi_day_high, oi_day_low) # [0, 100]
|
||||||
|
velocity = raptorbt.tick_velocity(ts_ns, 60.0) # ticks/min over last 60s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important for Zerodha data:** `total_buy_qty` and `total_sell_qty` from KiteTicker are cumulative session running sums, not per-tick values. Pass them as-is to `buy_sell_imbalance_delta` (it computes deltas internally). For `run_tick_backtest`, convert first: `buy_delta = np.diff(buy_cum, prepend=0).clip(min=0)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
@@ -646,78 +711,6 @@ final_values = result['final_values'] # numpy array, length = n_simulations
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## VectorBT Comparison
|
|
||||||
|
|
||||||
RaptorBT is designed as a drop-in replacement for VectorBT. Here's a side-by-side comparison:
|
|
||||||
|
|
||||||
### VectorBT (before)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import vectorbt as vbt
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
# Run backtest
|
|
||||||
pf = vbt.Portfolio.from_signals(
|
|
||||||
close=close_series,
|
|
||||||
entries=entries,
|
|
||||||
exits=exits,
|
|
||||||
init_cash=100000,
|
|
||||||
fees=0.001,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get metrics
|
|
||||||
print(pf.stats()["Total Return [%]"])
|
|
||||||
print(pf.stats()["Sharpe Ratio"])
|
|
||||||
print(pf.stats()["Max Drawdown [%]"])
|
|
||||||
```
|
|
||||||
|
|
||||||
### RaptorBT (after)
|
|
||||||
|
|
||||||
```python
|
|
||||||
import raptorbt
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
# Configure backtest
|
|
||||||
config = raptorbt.PyBacktestConfig(
|
|
||||||
initial_capital=100000,
|
|
||||||
fees=0.001,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run backtest
|
|
||||||
result = raptorbt.run_single_backtest(
|
|
||||||
timestamps=timestamps,
|
|
||||||
open=open_prices, high=high_prices,
|
|
||||||
low=low_prices, close=close_prices,
|
|
||||||
volume=volume,
|
|
||||||
entries=entries, exits=exits,
|
|
||||||
direction=1, weight=1.0,
|
|
||||||
symbol="SYMBOL",
|
|
||||||
config=config,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get metrics
|
|
||||||
print(f"Total Return: {result.metrics.total_return_pct}%")
|
|
||||||
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio}")
|
|
||||||
print(f"Max Drawdown: {result.metrics.max_drawdown_pct}%")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metric Mapping
|
|
||||||
|
|
||||||
| VectorBT Key | RaptorBT Attribute |
|
|
||||||
| ------------------ | -------------------------- |
|
|
||||||
| `Total Return [%]` | `metrics.total_return_pct` |
|
|
||||||
| `Sharpe Ratio` | `metrics.sharpe_ratio` |
|
|
||||||
| `Sortino Ratio` | `metrics.sortino_ratio` |
|
|
||||||
| `Max Drawdown [%]` | `metrics.max_drawdown_pct` |
|
|
||||||
| `Win Rate [%]` | `metrics.win_rate_pct` |
|
|
||||||
| `Profit Factor` | `metrics.profit_factor` |
|
|
||||||
| `SQN` | `metrics.sqn` |
|
|
||||||
| `Omega Ratio` | `metrics.omega_ratio` |
|
|
||||||
| `Total Trades` | `metrics.total_trades` |
|
|
||||||
| `Expectancy` | `metrics.expectancy` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### PyBacktestConfig
|
### PyBacktestConfig
|
||||||
@@ -864,7 +857,7 @@ metrics.open_trade_pnl
|
|||||||
metrics.payoff_ratio # avg win / avg loss (risk/reward per trade)
|
metrics.payoff_ratio # avg win / avg loss (risk/reward per trade)
|
||||||
metrics.recovery_factor # net profit / max drawdown (resilience)
|
metrics.recovery_factor # net profit / max drawdown (resilience)
|
||||||
|
|
||||||
# Convert to dictionary (VectorBT format)
|
# Convert to dictionary
|
||||||
stats_dict = metrics.to_dict()
|
stats_dict = metrics.to_dict()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -947,44 +940,32 @@ print(f'Total Return: {result.metrics.total_return_pct:.2f}%')
|
|||||||
print('RaptorBT is working correctly!')
|
print('RaptorBT is working correctly!')
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comparison Test (VectorBT vs RaptorBT)
|
### Verification Test
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
|
||||||
import vectorbt as vbt
|
|
||||||
import raptorbt
|
import raptorbt
|
||||||
|
|
||||||
# Create test data
|
|
||||||
np.random.seed(42)
|
np.random.seed(42)
|
||||||
n = 500
|
n = 500
|
||||||
dates = pd.date_range('2023-01-01', periods=n, freq='D')
|
|
||||||
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
|
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
|
||||||
entries = np.zeros(n, dtype=bool)
|
entries = np.zeros(n, dtype=bool)
|
||||||
exits = np.zeros(n, dtype=bool)
|
exits = np.zeros(n, dtype=bool)
|
||||||
entries[::20] = True
|
entries[::20] = True
|
||||||
exits[10::20] = True
|
exits[10::20] = True
|
||||||
|
|
||||||
# VectorBT
|
|
||||||
pf = vbt.Portfolio.from_signals(
|
|
||||||
close=pd.Series(close, index=dates),
|
|
||||||
entries=pd.Series(entries, index=dates),
|
|
||||||
exits=pd.Series(exits, index=dates),
|
|
||||||
init_cash=100000, fees=0.001
|
|
||||||
)
|
|
||||||
|
|
||||||
# RaptorBT
|
|
||||||
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
|
||||||
result = raptorbt.run_single_backtest(
|
result = raptorbt.run_single_backtest(
|
||||||
timestamps=dates.astype('int64').values,
|
timestamps=np.arange(n, dtype=np.int64),
|
||||||
open=close, high=close, low=close, close=close,
|
open=close, high=close, low=close, close=close,
|
||||||
volume=np.ones(n), entries=entries, exits=exits,
|
volume=np.ones(n), entries=entries, exits=exits,
|
||||||
direction=1, weight=1.0, symbol="TEST", config=config
|
direction=1, weight=1.0, symbol="TEST", config=config
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"VectorBT: {pf.stats()['Total Return [%]']:.4f}%")
|
print(f"Total Return: {result.metrics.total_return_pct:.4f}%")
|
||||||
print(f"RaptorBT: {result.metrics.total_return_pct:.4f}%")
|
print(f"Sharpe Ratio: {result.metrics.sharpe_ratio:.4f}")
|
||||||
# Results should match within 0.01%
|
print(f"Max Drawdown: {result.metrics.max_drawdown_pct:.4f}%")
|
||||||
|
print("RaptorBT is working correctly!")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -997,6 +978,23 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.4.0
|
||||||
|
|
||||||
|
**Tick-level backtesting — full tick resolution, no bar resampling.**
|
||||||
|
|
||||||
|
- Add `TickData` struct — parallel arrays of `timestamps`, `ltp`, `bid`, `ask`, `buy_qty_delta`, `sell_qty_delta`, `oi` (one element per tick). Callers must pre-convert Zerodha cumulative session totals to per-tick deltas before passing.
|
||||||
|
- Add `ExitReason::TimeExit` — max hold-time exceeded exit for tick strategies.
|
||||||
|
- Add `run_tick_backtest` — tick-native simulation engine. Entry fills at ask+slippage; stop/target checked against ltp on every tick (not OHLC approximation); max-hold-seconds time exit; configurable cooldown between entries. Returns the same `PyBacktestResult` / 27-metric `PyBacktestMetrics` as all other strategy types.
|
||||||
|
- Add `compute_tick_entry_signals` — compute momentum entry bool array from precomputed feature arrays (spread gate, delta BSI gate, 1-min return gate, cooldown enforcement). O(N) single pass.
|
||||||
|
- Add `compute_tick_exit_signals` — time-based (EOD) exit bool array from tick timestamps.
|
||||||
|
- Add `tick_spread_pct` — per-tick bid/ask spread as percentage of mid price.
|
||||||
|
- Add `buy_sell_imbalance_delta` — per-tick delta BSI from Zerodha cumulative running sums. Fixes the raw-cumulative BSI artefact (~0.95 all day regardless of order flow).
|
||||||
|
- Add `return_window` — per-tick lookback return over a configurable time window using binary search (O(N log N)). Returns NaN where history is insufficient — correctly gates the entry filter rather than silently passing.
|
||||||
|
- Add `realized_vol_rolling` — rolling realized volatility proxy (stddev of log-returns) over a time window.
|
||||||
|
- Add `oi_position_pct` — OI position within the day's high/low range, per tick: [0, 100].
|
||||||
|
- Add `tick_velocity` — rolling tick count per minute over a configurable time window.
|
||||||
|
- Expose `compute_backtest_metrics` as a public free function in `portfolio::engine` — non-OHLCV strategy types can produce identical metrics without duplicating the calculation logic.
|
||||||
|
|
||||||
### v0.3.4
|
### v0.3.4
|
||||||
|
|
||||||
- Add single-leg option spread types: `LongCall`, `LongPut`, `NakedCall`, `NakedPut` to `SpreadType` enum
|
- Add single-leg option spread types: `LongCall`, `LongPut`, `NakedCall`, `NakedPut` to `SpreadType` enum
|
||||||
@@ -1060,7 +1058,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
|||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
- 5 strategy types: single, basket, pairs, options, multi
|
- 5 strategy types: single, basket, pairs, options, multi
|
||||||
- 30+ performance metrics with full VectorBT parity
|
- 30+ performance metrics: Sharpe, Sortino, Calmar, Omega, SQN, profit factor, drawdown duration, and more
|
||||||
- 10 technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend)
|
- 10 technical indicators (SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend)
|
||||||
- Stop-loss management: fixed, ATR-based, and trailing stops
|
- Stop-loss management: fixed, ATR-based, and trailing stops
|
||||||
- Take-profit management: fixed, ATR-based, and risk-reward targets
|
- Take-profit management: fixed, ATR-based, and risk-reward targets
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.3.4"
|
version = "0.4.0"
|
||||||
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
|
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
RaptorBT - High-performance Rust backtesting engine.
|
RaptorBT - High-performance Rust backtesting engine.
|
||||||
|
|
||||||
This module provides Python bindings for the Rust-based backtesting engine,
|
Provides Python bindings for a Rust-based backtesting engine built for
|
||||||
offering significant performance improvements over vectorbt:
|
production quantitative trading:
|
||||||
- Disk footprint: <10MB (vs vectorbt's ~450MB)
|
- Sub-millisecond execution on thousands of bars
|
||||||
- Startup latency: <10ms (vs 200-600ms)
|
- Disk footprint: <10MB, startup latency: <10ms
|
||||||
- 100% deterministic execution (no JIT cache)
|
- 100% deterministic execution (no JIT cache)
|
||||||
- Native parallelism via Rayon + explicit SIMD
|
- Native parallelism via Rayon + explicit SIMD
|
||||||
|
- Full tick-level simulation (no bar resampling required)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from raptorbt._raptorbt import (
|
from raptorbt._raptorbt import (
|
||||||
@@ -26,11 +27,22 @@ from raptorbt._raptorbt import (
|
|||||||
run_pairs_backtest,
|
run_pairs_backtest,
|
||||||
run_multi_backtest,
|
run_multi_backtest,
|
||||||
run_spread_backtest,
|
run_spread_backtest,
|
||||||
|
run_tick_backtest,
|
||||||
# Batch backtest
|
# Batch backtest
|
||||||
PyBatchSpreadItem,
|
PyBatchSpreadItem,
|
||||||
batch_spread_backtest,
|
batch_spread_backtest,
|
||||||
# Monte Carlo simulation
|
# Monte Carlo simulation
|
||||||
simulate_portfolio_mc,
|
simulate_portfolio_mc,
|
||||||
|
# Tick signal functions
|
||||||
|
compute_tick_entry_signals,
|
||||||
|
compute_tick_exit_signals,
|
||||||
|
# Tick feature functions
|
||||||
|
tick_spread_pct,
|
||||||
|
buy_sell_imbalance_delta,
|
||||||
|
return_window,
|
||||||
|
realized_vol_rolling,
|
||||||
|
oi_position_pct,
|
||||||
|
tick_velocity,
|
||||||
# Indicator functions
|
# Indicator functions
|
||||||
sma,
|
sma,
|
||||||
ema,
|
ema,
|
||||||
@@ -46,7 +58,7 @@ from raptorbt._raptorbt import (
|
|||||||
rolling_max,
|
rolling_max,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.3.4"
|
__version__ = "0.4.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config classes
|
# Config classes
|
||||||
@@ -65,11 +77,22 @@ __all__ = [
|
|||||||
"run_pairs_backtest",
|
"run_pairs_backtest",
|
||||||
"run_multi_backtest",
|
"run_multi_backtest",
|
||||||
"run_spread_backtest",
|
"run_spread_backtest",
|
||||||
|
"run_tick_backtest",
|
||||||
# Batch backtest
|
# Batch backtest
|
||||||
"PyBatchSpreadItem",
|
"PyBatchSpreadItem",
|
||||||
"batch_spread_backtest",
|
"batch_spread_backtest",
|
||||||
# Monte Carlo simulation
|
# Monte Carlo simulation
|
||||||
"simulate_portfolio_mc",
|
"simulate_portfolio_mc",
|
||||||
|
# Tick signal functions
|
||||||
|
"compute_tick_entry_signals",
|
||||||
|
"compute_tick_exit_signals",
|
||||||
|
# Tick feature functions
|
||||||
|
"tick_spread_pct",
|
||||||
|
"buy_sell_imbalance_delta",
|
||||||
|
"return_window",
|
||||||
|
"realized_vol_rolling",
|
||||||
|
"oi_position_pct",
|
||||||
|
"tick_velocity",
|
||||||
# Indicator functions
|
# Indicator functions
|
||||||
"sma",
|
"sma",
|
||||||
"ema",
|
"ema",
|
||||||
|
|||||||
+41
-1
@@ -104,6 +104,44 @@ impl OhlcvData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raw tick data series for tick-level backtesting.
|
||||||
|
///
|
||||||
|
/// All fields are parallel arrays of length N (one entry per tick).
|
||||||
|
/// `buy_qty_delta` and `sell_qty_delta` must be per-tick deltas, not
|
||||||
|
/// cumulative session totals — callers are responsible for converting
|
||||||
|
/// Zerodha-style running sums before passing them here.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TickData {
|
||||||
|
/// Nanoseconds-since-epoch timestamp for each tick.
|
||||||
|
pub timestamps: Vec<Timestamp>,
|
||||||
|
/// Last traded price at each tick.
|
||||||
|
pub ltp: Vec<Price>,
|
||||||
|
/// Best bid price at each tick (0.0 if unavailable).
|
||||||
|
pub bid: Vec<Price>,
|
||||||
|
/// Best ask price at each tick (0.0 if unavailable).
|
||||||
|
pub ask: Vec<Price>,
|
||||||
|
/// Per-tick buy quantity delta (not cumulative).
|
||||||
|
pub buy_qty_delta: Vec<f64>,
|
||||||
|
/// Per-tick sell quantity delta (not cumulative).
|
||||||
|
pub sell_qty_delta: Vec<f64>,
|
||||||
|
/// Open interest at each tick (0 if unavailable).
|
||||||
|
pub oi: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TickData {
|
||||||
|
/// Number of ticks.
|
||||||
|
#[inline]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.ltp.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the series is empty.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.ltp.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compiled trading signals from strategy.
|
/// Compiled trading signals from strategy.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CompiledSignals {
|
pub struct CompiledSignals {
|
||||||
@@ -214,6 +252,8 @@ pub enum ExitReason {
|
|||||||
EndOfData,
|
EndOfData,
|
||||||
/// Option expiry settlement.
|
/// Option expiry settlement.
|
||||||
Settlement,
|
Settlement,
|
||||||
|
/// Max hold time exceeded (tick backtest).
|
||||||
|
TimeExit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backtest configuration.
|
/// Backtest configuration.
|
||||||
@@ -433,7 +473,7 @@ pub struct Position {
|
|||||||
pub highest_since_entry: Price,
|
pub highest_since_entry: Price,
|
||||||
/// Lowest price since entry (for trailing stops).
|
/// Lowest price since entry (for trailing stops).
|
||||||
pub lowest_since_entry: Price,
|
pub lowest_since_entry: Price,
|
||||||
/// Entry fees (to include in trade PnL like VectorBT).
|
/// Entry fees included in trade PnL.
|
||||||
pub entry_fees: f64,
|
pub entry_fees: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
pub mod momentum;
|
pub mod momentum;
|
||||||
pub mod rolling;
|
pub mod rolling;
|
||||||
pub mod strength;
|
pub mod strength;
|
||||||
|
pub mod tick_features;
|
||||||
pub mod trend;
|
pub mod trend;
|
||||||
pub mod volatility;
|
pub mod volatility;
|
||||||
pub mod volume;
|
pub mod volume;
|
||||||
@@ -13,6 +14,10 @@ pub mod volume;
|
|||||||
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
|
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
|
||||||
pub use rolling::{rolling_max, rolling_min};
|
pub use rolling::{rolling_max, rolling_min};
|
||||||
pub use strength::adx;
|
pub use strength::adx;
|
||||||
|
pub use tick_features::{
|
||||||
|
buy_sell_imbalance_delta, oi_position_pct, realized_vol_rolling, return_window, spread_pct,
|
||||||
|
tick_velocity,
|
||||||
|
};
|
||||||
pub use trend::{ema, sma, supertrend, SupertrendResult};
|
pub use trend::{ema, sma, supertrend, SupertrendResult};
|
||||||
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
|
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
|
||||||
pub use volume::{obv, vwap};
|
pub use volume::{obv, vwap};
|
||||||
|
|||||||
@@ -276,9 +276,9 @@ mod tests {
|
|||||||
assert!(result.macd_line[24].is_nan());
|
assert!(result.macd_line[24].is_nan());
|
||||||
assert!(!result.macd_line[25].is_nan());
|
assert!(!result.macd_line[25].is_nan());
|
||||||
|
|
||||||
// Signal line should be valid later
|
// Signal line starts at index slow_period-1 + signal_period-1 = 25+8 = 33
|
||||||
assert!(result.signal_line[33].is_nan());
|
assert!(result.signal_line[32].is_nan());
|
||||||
assert!(!result.signal_line[34].is_nan());
|
assert!(!result.signal_line[33].is_nan());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//! Tick-level feature extraction functions.
|
||||||
|
//!
|
||||||
|
//! All functions accept parallel arrays (one element per tick) and return a
|
||||||
|
//! Vec<f64> of the same length. NaN is used where the feature is undefined
|
||||||
|
//! (e.g. insufficient history for a lookback window).
|
||||||
|
//!
|
||||||
|
//! These are building blocks for the signal generation layer — compute features
|
||||||
|
//! once on the full tick window, then pass the resulting arrays to
|
||||||
|
//! `tick_signals::tick_momentum_entry`.
|
||||||
|
|
||||||
|
/// Per-tick bid/ask spread as a percentage of the mid price.
|
||||||
|
///
|
||||||
|
/// Returns 0.0 where both bid and ask are zero.
|
||||||
|
pub fn spread_pct(bid: &[f64], ask: &[f64]) -> Vec<f64> {
|
||||||
|
bid.iter()
|
||||||
|
.zip(ask.iter())
|
||||||
|
.map(|(&b, &a)| {
|
||||||
|
let mid = (b + a) / 2.0;
|
||||||
|
if mid > 0.0 {
|
||||||
|
(a - b) / mid * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick delta BSI from Zerodha cumulative session totals.
|
||||||
|
///
|
||||||
|
/// Zerodha's `total_buy_qty` / `total_sell_qty` are running sums that grow
|
||||||
|
/// monotonically from market open. Computing BSI from raw cumulative values
|
||||||
|
/// yields ~0.95 for the whole day (artefact of early-session buy-side dominance).
|
||||||
|
///
|
||||||
|
/// This function computes the imbalance of the most recent tick's activity only:
|
||||||
|
/// `bsi[i] = Δbuy[i] / (Δbuy[i] + Δsell[i])` where `Δbuy[i] = max(0, buy[i] - buy[i-1])`
|
||||||
|
///
|
||||||
|
/// Returns 0.5 (neutral) where the total delta is zero (no activity).
|
||||||
|
pub fn buy_sell_imbalance_delta(
|
||||||
|
buy_qty_cumulative: &[f64],
|
||||||
|
sell_qty_cumulative: &[f64],
|
||||||
|
) -> Vec<f64> {
|
||||||
|
let n = buy_qty_cumulative.len();
|
||||||
|
let mut out = vec![0.5_f64; n];
|
||||||
|
for i in 1..n {
|
||||||
|
let db = (buy_qty_cumulative[i] - buy_qty_cumulative[i - 1]).max(0.0);
|
||||||
|
let ds = (sell_qty_cumulative[i] - sell_qty_cumulative[i - 1]).max(0.0);
|
||||||
|
let total = db + ds;
|
||||||
|
if total > 0.0 {
|
||||||
|
out[i] = db / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick lookback return over a fixed time window.
|
||||||
|
///
|
||||||
|
/// For each tick i, finds the latest tick whose timestamp is at most
|
||||||
|
/// `timestamps_ns[i] - window_seconds * 1e9` and computes:
|
||||||
|
/// `(ltp[i] - ltp_ref) / ltp_ref * 100`
|
||||||
|
///
|
||||||
|
/// Returns `f64::NAN` for ticks where no reference tick exists (start of series
|
||||||
|
/// or insufficient history).
|
||||||
|
///
|
||||||
|
/// Uses binary search → O(N log N) total.
|
||||||
|
pub fn return_window(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec<f64> {
|
||||||
|
let n = timestamps_ns.len();
|
||||||
|
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
|
||||||
|
let mut out = vec![f64::NAN; n];
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let cutoff = timestamps_ns[i] - window_ns;
|
||||||
|
// Binary search for the last index with ts <= cutoff
|
||||||
|
let pos = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff);
|
||||||
|
// pos is the first index > cutoff; we want pos.saturating_sub(1)
|
||||||
|
if pos > 0 {
|
||||||
|
let ref_idx = pos - 1;
|
||||||
|
let ltp_ref = ltp[ref_idx];
|
||||||
|
if ltp_ref > 0.0 {
|
||||||
|
out[i] = (ltp[i] - ltp_ref) / ltp_ref * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling realized volatility proxy: annualized stddev of log returns.
|
||||||
|
///
|
||||||
|
/// For each tick i, computes stddev of log-returns over all ticks within
|
||||||
|
/// the preceding `window_seconds`. Returns `f64::NAN` if fewer than 2 ticks
|
||||||
|
/// in the window.
|
||||||
|
///
|
||||||
|
/// O(N²) worst case but typical windows are short (60–300 s at ~80 ticks/min
|
||||||
|
/// = 80–400 ticks), making the inner loop fast in practice.
|
||||||
|
pub fn realized_vol_rolling(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec<f64> {
|
||||||
|
let n = timestamps_ns.len();
|
||||||
|
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
|
||||||
|
let mut out = vec![f64::NAN; n];
|
||||||
|
|
||||||
|
for i in 1..n {
|
||||||
|
let cutoff = timestamps_ns[i] - window_ns;
|
||||||
|
// Find the first tick inside the window
|
||||||
|
let start = timestamps_ns[..i].partition_point(|&ts| ts < cutoff);
|
||||||
|
// We need log returns from start..=i
|
||||||
|
let count = i - start;
|
||||||
|
if count < 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut log_rets = Vec::with_capacity(count);
|
||||||
|
for j in (start + 1)..=i {
|
||||||
|
if ltp[j - 1] > 0.0 {
|
||||||
|
log_rets.push((ltp[j] / ltp[j - 1]).ln());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if log_rets.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mean = log_rets.iter().sum::<f64>() / log_rets.len() as f64;
|
||||||
|
let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
|
||||||
|
/ (log_rets.len() - 1) as f64;
|
||||||
|
out[i] = variance.sqrt() * 100.0; // as percentage of price
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick OI position within the day's high/low range.
|
||||||
|
///
|
||||||
|
/// Returns `(oi[i] - oi_day_low) / (oi_day_high - oi_day_low) * 100` ∈ [0, 100].
|
||||||
|
/// Returns `f64::NAN` where `oi_day_high <= oi_day_low`.
|
||||||
|
pub fn oi_position_pct(oi: &[f64], oi_day_high: f64, oi_day_low: f64) -> Vec<f64> {
|
||||||
|
let range = oi_day_high - oi_day_low;
|
||||||
|
if range <= 0.0 {
|
||||||
|
return vec![f64::NAN; oi.len()];
|
||||||
|
}
|
||||||
|
oi.iter()
|
||||||
|
.map(|&o| (o - oi_day_low) / range * 100.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling tick velocity: number of ticks per minute in the preceding window.
|
||||||
|
///
|
||||||
|
/// For each tick i, counts ticks in (timestamps_ns[i] - window_seconds*1e9, timestamps_ns[i]].
|
||||||
|
/// Returns 0.0 for the first tick.
|
||||||
|
pub fn tick_velocity(timestamps_ns: &[i64], window_seconds: f64) -> Vec<f64> {
|
||||||
|
let n = timestamps_ns.len();
|
||||||
|
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
|
||||||
|
let mut out = vec![0.0_f64; n];
|
||||||
|
|
||||||
|
for i in 1..n {
|
||||||
|
let cutoff = timestamps_ns[i] - window_ns;
|
||||||
|
let start = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff);
|
||||||
|
let count = (i - start + 1) as f64; // include current tick
|
||||||
|
let minutes = window_seconds / 60.0;
|
||||||
|
out[i] = if minutes > 0.0 { count / minutes } else { 0.0 };
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spread_pct_basic() {
|
||||||
|
let bid = vec![100.0, 200.0];
|
||||||
|
let ask = vec![101.0, 202.0];
|
||||||
|
let s = spread_pct(&bid, &ask);
|
||||||
|
// (101-100)/100.5 * 100 ≈ 0.995
|
||||||
|
assert!((s[0] - 0.9950248756218905).abs() < 1e-9);
|
||||||
|
// (202-200)/201 * 100 ≈ 0.995
|
||||||
|
assert!((s[1] - 0.9950248756218905).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spread_pct_zero_bid_ask() {
|
||||||
|
let bid = vec![0.0];
|
||||||
|
let ask = vec![0.0];
|
||||||
|
let s = spread_pct(&bid, &ask);
|
||||||
|
assert_eq!(s[0], 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bsi_delta_basic() {
|
||||||
|
// Cumulative: buy grows by 100, sell by 0 → bsi = 1.0
|
||||||
|
let buy = vec![1000.0, 1100.0, 1100.0, 1150.0];
|
||||||
|
let sell = vec![800.0, 800.0, 850.0, 850.0];
|
||||||
|
let bsi = buy_sell_imbalance_delta(&buy, &sell);
|
||||||
|
assert_eq!(bsi[0], 0.5); // first tick always neutral
|
||||||
|
assert_eq!(bsi[1], 1.0); // all buy
|
||||||
|
assert_eq!(bsi[2], 0.0); // all sell
|
||||||
|
assert_eq!(bsi[3], 1.0); // all buy
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bsi_delta_no_activity() {
|
||||||
|
// No change → neutral 0.5
|
||||||
|
let buy = vec![1000.0, 1000.0];
|
||||||
|
let sell = vec![800.0, 800.0];
|
||||||
|
let bsi = buy_sell_imbalance_delta(&buy, &sell);
|
||||||
|
assert_eq!(bsi[1], 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_return_window_basic() {
|
||||||
|
// Ticks at 0s, 30s, 61s, 90s (nanoseconds)
|
||||||
|
let sec = 1_000_000_000_i64;
|
||||||
|
let ts = vec![0, 30 * sec, 61 * sec, 90 * sec];
|
||||||
|
let ltp = vec![100.0, 102.0, 101.0, 105.0];
|
||||||
|
let ret = return_window(&ts, <p, 60.0);
|
||||||
|
// ts[0]: no history → NAN
|
||||||
|
assert!(ret[0].is_nan());
|
||||||
|
// ts[1] at 30s: no tick <= -30s → NAN
|
||||||
|
assert!(ret[1].is_nan());
|
||||||
|
// ts[2] at 61s: cutoff = 1s, ts[0]=0 ≤ 1s → ref = ltp[0]=100.0
|
||||||
|
// (101 - 100) / 100 * 100 = 1.0
|
||||||
|
assert!((ret[2] - 1.0).abs() < 1e-9);
|
||||||
|
// ts[3] at 90s: cutoff = 30s, ts[1]=30s ≤ 30s → ref = ltp[1]=102.0
|
||||||
|
// (105 - 102) / 102 * 100 ≈ 2.941
|
||||||
|
assert!((ret[3] - (3.0 / 102.0 * 100.0)).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oi_position_pct() {
|
||||||
|
let oi = vec![50.0, 100.0, 150.0];
|
||||||
|
let result = oi_position_pct(&oi, 200.0, 0.0);
|
||||||
|
assert_eq!(result, vec![25.0, 50.0, 75.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_oi_position_pct_no_range() {
|
||||||
|
let oi = vec![100.0, 100.0];
|
||||||
|
let result = oi_position_pct(&oi, 100.0, 100.0);
|
||||||
|
assert!(result[0].is_nan());
|
||||||
|
assert!(result[1].is_nan());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tick_velocity_basic() {
|
||||||
|
// 4 ticks at 0s, 10s, 20s, 30s; window=60s
|
||||||
|
let sec = 1_000_000_000_i64;
|
||||||
|
let ts = vec![0, 10 * sec, 20 * sec, 30 * sec];
|
||||||
|
let vel = tick_velocity(&ts, 60.0);
|
||||||
|
// At i=3 (30s): ticks in (−30s, 30s] = all 4 → 4 ticks / 1 min = 4.0
|
||||||
|
assert_eq!(vel[0], 0.0);
|
||||||
|
assert!((vel[3] - 4.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -43,6 +43,7 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
|||||||
m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::run_tick_backtest, m)?)?;
|
||||||
|
|
||||||
// Register batch spread backtest
|
// Register batch spread backtest
|
||||||
m.add_class::<python::bindings::PyBatchSpreadItem>()?;
|
m.add_class::<python::bindings::PyBatchSpreadItem>()?;
|
||||||
@@ -51,6 +52,18 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
|||||||
// Register Monte Carlo simulation
|
// Register Monte Carlo simulation
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?;
|
||||||
|
|
||||||
|
// Register tick signal functions
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?;
|
||||||
|
|
||||||
|
// Register tick feature functions
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?;
|
||||||
|
|
||||||
// Register indicator functions
|
// Register indicator functions
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
|
||||||
|
|||||||
+25
-9
@@ -237,8 +237,8 @@ impl PortfolioEngine {
|
|||||||
.map(|cap| cap.min(cash))
|
.map(|cap| cap.min(cash))
|
||||||
.unwrap_or(cash);
|
.unwrap_or(cash);
|
||||||
|
|
||||||
// VectorBT formula: size = cash / (price * (1 + fees))
|
// Position sizing: size = cash / (price * (1 + fees))
|
||||||
// This ensures the position value plus entry fee equals available cash
|
// Ensures position value plus entry fee equals available cash
|
||||||
let fee_rate = self.config.fees;
|
let fee_rate = self.config.fees;
|
||||||
let raw_size = if let Some(ref sizes) = signals.position_sizes {
|
let raw_size = if let Some(ref sizes) = signals.position_sizes {
|
||||||
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
|
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
|
||||||
@@ -299,12 +299,11 @@ impl PortfolioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark any open position at end of data (no exit fees, matching VectorBT behavior)
|
// Mark any open position at end of data — marked-to-market, no exit fees
|
||||||
if position.is_in_position() {
|
if position.is_in_position() {
|
||||||
let last_idx = n - 1;
|
let last_idx = n - 1;
|
||||||
let exit_price = ohlcv.close[last_idx];
|
let exit_price = ohlcv.close[last_idx];
|
||||||
// No exit fees for EndOfData - position is marked-to-market but not actually closed
|
// No exit fees for EndOfData: position is marked-to-market but not actually closed
|
||||||
// This matches VectorBT's behavior for "Open" trades
|
|
||||||
let exit_fees = 0.0;
|
let exit_fees = 0.0;
|
||||||
|
|
||||||
if let Some(trade) = position.close_position(
|
if let Some(trade) = position.close_position(
|
||||||
@@ -572,11 +571,9 @@ impl PortfolioEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
|
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
|
||||||
// This matches VectorBT's calculation methodology
|
|
||||||
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
|
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
|
||||||
|
|
||||||
// Calmar ratio: CAGR / max drawdown
|
// Calmar ratio: CAGR / max drawdown
|
||||||
// VectorBT uses Compound Annual Growth Rate (CAGR)
|
|
||||||
let num_periods = equity_curve.len().max(1) as f64;
|
let num_periods = equity_curve.len().max(1) as f64;
|
||||||
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
||||||
let total_return_frac = total_return_pct / 100.0;
|
let total_return_frac = total_return_pct / 100.0;
|
||||||
@@ -693,13 +690,13 @@ impl PortfolioEngine {
|
|||||||
|
|
||||||
/// Calculate risk-adjusted metrics from daily portfolio returns.
|
/// Calculate risk-adjusted metrics from daily portfolio returns.
|
||||||
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
|
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
|
||||||
/// Uses 365 days for annualization to match VectorBT.
|
/// Uses 365 calendar days for annualization.
|
||||||
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
|
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
|
||||||
if returns.len() < 2 {
|
if returns.len() < 2 {
|
||||||
return (0.0, 0.0, 1.0);
|
return (0.0, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// VectorBT uses 365 days (calendar days) for annualization
|
// 365 calendar days for annualization
|
||||||
let periods_per_year: f64 = 365.0;
|
let periods_per_year: f64 = 365.0;
|
||||||
let _n = returns.len() as f64;
|
let _n = returns.len() as f64;
|
||||||
|
|
||||||
@@ -761,6 +758,25 @@ impl PortfolioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute `BacktestMetrics` from pre-built curves and trade list.
|
||||||
|
///
|
||||||
|
/// Exposed as a standalone function so non-OHLCV strategies (e.g. tick backtest)
|
||||||
|
/// can produce identical metrics without duplicating the calculation logic.
|
||||||
|
pub fn compute_backtest_metrics(
|
||||||
|
equity_curve: &[f64],
|
||||||
|
drawdown_curve: &[f64],
|
||||||
|
returns: &[f64],
|
||||||
|
trades: &[Trade],
|
||||||
|
initial_capital: f64,
|
||||||
|
) -> BacktestMetrics {
|
||||||
|
// Delegate to a throwaway engine instance — avoids duplicating the logic.
|
||||||
|
let engine = PortfolioEngine::new(BacktestConfig {
|
||||||
|
initial_capital,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
engine.calculate_metrics(equity_curve, drawdown_curve, returns, trades, &StreamingMetrics::new())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl PositionManager {
|
|||||||
let pos = &self.position;
|
let pos = &self.position;
|
||||||
let multiplier = pos.direction.multiplier();
|
let multiplier = pos.direction.multiplier();
|
||||||
|
|
||||||
// Calculate P&L (matching VectorBT: gross - entry_fees - exit_fees)
|
// Calculate P&L: gross - entry_fees - exit_fees
|
||||||
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
|
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
|
||||||
let total_fees = pos.entry_fees + exit_fees;
|
let total_fees = pos.entry_fees + exit_fees;
|
||||||
let pnl = gross_pnl - total_fees;
|
let pnl = gross_pnl - total_fees;
|
||||||
|
|||||||
+273
-1
@@ -21,6 +21,7 @@ use crate::strategies::single::SingleBacktest;
|
|||||||
use crate::strategies::spreads::{
|
use crate::strategies::spreads::{
|
||||||
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
||||||
};
|
};
|
||||||
|
use crate::strategies::tick::{TickBacktest, TickBacktestConfig};
|
||||||
|
|
||||||
use super::numpy_bridge::*;
|
use super::numpy_bridge::*;
|
||||||
|
|
||||||
@@ -434,7 +435,7 @@ impl PyBacktestMetrics {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert to dictionary matching VectorBT stats() format.
|
/// Convert to dictionary of all metrics.
|
||||||
fn to_dict(&self, py: Python) -> PyResult<PyObject> {
|
fn to_dict(&self, py: Python) -> PyResult<PyObject> {
|
||||||
let dict = pyo3::types::PyDict::new(py);
|
let dict = pyo3::types::PyDict::new(py);
|
||||||
dict.set_item("Start Value", self.start_value)?;
|
dict.set_item("Start Value", self.start_value)?;
|
||||||
@@ -1049,6 +1050,277 @@ pub fn run_multi_backtest<'py>(
|
|||||||
Ok(convert_result(result))
|
Ok(convert_result(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run tick-level backtest on a single instrument.
|
||||||
|
///
|
||||||
|
/// All arrays must be the same length N (one element per tick).
|
||||||
|
/// `buy_qty_delta` and `sell_qty_delta` must already be per-tick deltas —
|
||||||
|
/// pass the difference from the previous tick, not Zerodha's cumulative totals.
|
||||||
|
/// `entries` / `exits` are caller-computed boolean signal arrays.
|
||||||
|
///
|
||||||
|
/// Returns a `PyBacktestResult` with the same fields as `run_single_backtest`.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (
|
||||||
|
timestamps,
|
||||||
|
ltp,
|
||||||
|
bid,
|
||||||
|
ask,
|
||||||
|
buy_qty_delta,
|
||||||
|
sell_qty_delta,
|
||||||
|
oi,
|
||||||
|
entries,
|
||||||
|
exits,
|
||||||
|
symbol = "TICK",
|
||||||
|
initial_capital = 100_000.0,
|
||||||
|
fees = 0.001,
|
||||||
|
slippage = 0.0,
|
||||||
|
stop_loss_pct = 5.0,
|
||||||
|
take_profit_pct = 10.0,
|
||||||
|
max_hold_seconds = 1800_u64,
|
||||||
|
entry_cooldown_ticks = 10_usize,
|
||||||
|
max_trades = 50_usize,
|
||||||
|
))]
|
||||||
|
pub fn run_tick_backtest<'py>(
|
||||||
|
_py: Python<'py>,
|
||||||
|
timestamps: PyReadonlyArray1<i64>,
|
||||||
|
ltp: PyReadonlyArray1<f64>,
|
||||||
|
bid: PyReadonlyArray1<f64>,
|
||||||
|
ask: PyReadonlyArray1<f64>,
|
||||||
|
buy_qty_delta: PyReadonlyArray1<f64>,
|
||||||
|
sell_qty_delta: PyReadonlyArray1<f64>,
|
||||||
|
oi: PyReadonlyArray1<f64>,
|
||||||
|
entries: PyReadonlyArray1<bool>,
|
||||||
|
exits: PyReadonlyArray1<bool>,
|
||||||
|
symbol: &str,
|
||||||
|
initial_capital: f64,
|
||||||
|
fees: f64,
|
||||||
|
slippage: f64,
|
||||||
|
stop_loss_pct: f64,
|
||||||
|
take_profit_pct: f64,
|
||||||
|
max_hold_seconds: u64,
|
||||||
|
entry_cooldown_ticks: usize,
|
||||||
|
max_trades: usize,
|
||||||
|
) -> PyResult<PyBacktestResult> {
|
||||||
|
let tick_data = crate::core::types::TickData {
|
||||||
|
timestamps: numpy_to_vec_i64(timestamps),
|
||||||
|
ltp: numpy_to_vec_f64(ltp),
|
||||||
|
bid: numpy_to_vec_f64(bid),
|
||||||
|
ask: numpy_to_vec_f64(ask),
|
||||||
|
buy_qty_delta: numpy_to_vec_f64(buy_qty_delta),
|
||||||
|
sell_qty_delta: numpy_to_vec_f64(sell_qty_delta),
|
||||||
|
oi: numpy_to_vec_f64(oi),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_signals = numpy_to_vec_bool(entries);
|
||||||
|
let exit_signals = numpy_to_vec_bool(exits);
|
||||||
|
|
||||||
|
let config = TickBacktestConfig {
|
||||||
|
base: crate::core::types::BacktestConfig {
|
||||||
|
initial_capital,
|
||||||
|
fees,
|
||||||
|
slippage,
|
||||||
|
stop: crate::core::types::StopConfig::None,
|
||||||
|
target: crate::core::types::TargetConfig::None,
|
||||||
|
upon_bar_close: false,
|
||||||
|
},
|
||||||
|
stop_loss_pct,
|
||||||
|
take_profit_pct,
|
||||||
|
max_hold_seconds,
|
||||||
|
entry_cooldown_ticks,
|
||||||
|
max_trades,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backtest = TickBacktest::new(config);
|
||||||
|
let result = backtest.run(&tick_data, &entry_signals, &exit_signals, symbol);
|
||||||
|
|
||||||
|
Ok(convert_result(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tick Signal Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Compute tick momentum entry signals from per-tick feature arrays.
|
||||||
|
///
|
||||||
|
/// All input arrays must have the same length N. Returns a bool array of length N
|
||||||
|
/// where True indicates a valid entry tick (all gates passed, not in cooldown).
|
||||||
|
///
|
||||||
|
/// Gates (each can be disabled by setting threshold to 0.0):
|
||||||
|
/// - spread_pct[i] <= spread_pct_max
|
||||||
|
/// - bsi_delta[i] >= bsi_min (0.0 = disabled)
|
||||||
|
/// - |return_1m[i]| >= return_1m_min_abs (0.0 = disabled; NaN always fails)
|
||||||
|
/// - cooldown_ticks between consecutive entries
|
||||||
|
///
|
||||||
|
/// return_direction: +1 for long (needs positive return_1m), -1 for short.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (
|
||||||
|
spread_pct,
|
||||||
|
bsi_delta,
|
||||||
|
return_1m,
|
||||||
|
spread_pct_max = 5.0,
|
||||||
|
bsi_min = 0.0,
|
||||||
|
return_1m_min_abs = 0.0,
|
||||||
|
return_direction = 1_i8,
|
||||||
|
cooldown_ticks = 10_usize,
|
||||||
|
))]
|
||||||
|
pub fn compute_tick_entry_signals<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
spread_pct: PyReadonlyArray1<f64>,
|
||||||
|
bsi_delta: PyReadonlyArray1<f64>,
|
||||||
|
return_1m: PyReadonlyArray1<f64>,
|
||||||
|
spread_pct_max: f64,
|
||||||
|
bsi_min: f64,
|
||||||
|
return_1m_min_abs: f64,
|
||||||
|
return_direction: i8,
|
||||||
|
cooldown_ticks: usize,
|
||||||
|
) -> PyResult<&'py PyArray1<bool>> {
|
||||||
|
let result = crate::signals::tick_signals::tick_momentum_entry(
|
||||||
|
&numpy_to_vec_f64(spread_pct),
|
||||||
|
&numpy_to_vec_f64(bsi_delta),
|
||||||
|
&numpy_to_vec_f64(return_1m),
|
||||||
|
spread_pct_max,
|
||||||
|
bsi_min,
|
||||||
|
return_1m_min_abs,
|
||||||
|
return_direction,
|
||||||
|
cooldown_ticks,
|
||||||
|
);
|
||||||
|
Ok(vec_to_numpy_bool(py, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute time-based exit signals (EOD / session-end).
|
||||||
|
///
|
||||||
|
/// Sets exit[i] = True for every tick with timestamp >= eod_exit_time_ns.
|
||||||
|
/// Set eod_exit_time_ns = 0 to disable (returns all False).
|
||||||
|
///
|
||||||
|
/// timestamps_ns: nanoseconds-since-epoch for each tick (int64 array).
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps_ns, eod_exit_time_ns = 0_i64))]
|
||||||
|
pub fn compute_tick_exit_signals<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
timestamps_ns: PyReadonlyArray1<i64>,
|
||||||
|
eod_exit_time_ns: i64,
|
||||||
|
) -> PyResult<&'py PyArray1<bool>> {
|
||||||
|
let result = crate::signals::tick_signals::tick_momentum_exit(
|
||||||
|
&numpy_to_vec_i64(timestamps_ns),
|
||||||
|
eod_exit_time_ns,
|
||||||
|
);
|
||||||
|
Ok(vec_to_numpy_bool(py, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tick Feature Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Per-tick bid/ask spread as percentage of mid price.
|
||||||
|
/// Returns 0.0 where both bid and ask are zero.
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn tick_spread_pct<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
bid: PyReadonlyArray1<f64>,
|
||||||
|
ask: PyReadonlyArray1<f64>,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::spread_pct(&numpy_to_vec_f64(bid), &numpy_to_vec_f64(ask)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick delta BSI from Zerodha cumulative session totals.
|
||||||
|
///
|
||||||
|
/// buy_qty_cumulative / sell_qty_cumulative must be the raw cumulative running sums
|
||||||
|
/// from Zerodha (NOT already-converted deltas). Returns [0, 1] per tick; 0.5 = neutral.
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn buy_sell_imbalance_delta<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
buy_qty_cumulative: PyReadonlyArray1<f64>,
|
||||||
|
sell_qty_cumulative: PyReadonlyArray1<f64>,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::buy_sell_imbalance_delta(
|
||||||
|
&numpy_to_vec_f64(buy_qty_cumulative),
|
||||||
|
&numpy_to_vec_f64(sell_qty_cumulative),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick lookback return over a time window.
|
||||||
|
///
|
||||||
|
/// timestamps_ns: nanoseconds-since-epoch for each tick.
|
||||||
|
/// Returns NaN for ticks without sufficient history.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 60.0))]
|
||||||
|
pub fn return_window<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
timestamps_ns: PyReadonlyArray1<i64>,
|
||||||
|
ltp: PyReadonlyArray1<f64>,
|
||||||
|
window_seconds: f64,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::return_window(
|
||||||
|
&numpy_to_vec_i64(timestamps_ns),
|
||||||
|
&numpy_to_vec_f64(ltp),
|
||||||
|
window_seconds,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling realized volatility proxy: stddev of log-returns over a time window (as %).
|
||||||
|
/// Returns NaN for ticks without at least 2 data points in the window.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 300.0))]
|
||||||
|
pub fn realized_vol_rolling<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
timestamps_ns: PyReadonlyArray1<i64>,
|
||||||
|
ltp: PyReadonlyArray1<f64>,
|
||||||
|
window_seconds: f64,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::realized_vol_rolling(
|
||||||
|
&numpy_to_vec_i64(timestamps_ns),
|
||||||
|
&numpy_to_vec_f64(ltp),
|
||||||
|
window_seconds,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick OI position within the day's high/low range: [0, 100].
|
||||||
|
/// Returns NaN where oi_day_high <= oi_day_low.
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn oi_position_pct<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
oi: PyReadonlyArray1<f64>,
|
||||||
|
oi_day_high: f64,
|
||||||
|
oi_day_low: f64,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::oi_position_pct(
|
||||||
|
&numpy_to_vec_f64(oi),
|
||||||
|
oi_day_high,
|
||||||
|
oi_day_low,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling tick velocity: ticks per minute over the preceding window_seconds.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps_ns, window_seconds = 60.0))]
|
||||||
|
pub fn tick_velocity<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
timestamps_ns: PyReadonlyArray1<i64>,
|
||||||
|
window_seconds: f64,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
Ok(vec_to_numpy_f64(
|
||||||
|
py,
|
||||||
|
crate::indicators::tick_features::tick_velocity(
|
||||||
|
&numpy_to_vec_i64(timestamps_ns),
|
||||||
|
window_seconds,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Indicator Functions
|
// Indicator Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
pub mod expression;
|
pub mod expression;
|
||||||
pub mod processor;
|
pub mod processor;
|
||||||
pub mod synchronizer;
|
pub mod synchronizer;
|
||||||
|
pub mod tick_signals;
|
||||||
|
|
||||||
pub use processor::SignalProcessor;
|
pub use processor::SignalProcessor;
|
||||||
pub use synchronizer::{SignalSynchronizer, SyncMode};
|
pub use synchronizer::{SignalSynchronizer, SyncMode};
|
||||||
|
pub use tick_signals::{tick_momentum_entry, tick_momentum_exit};
|
||||||
|
|||||||
@@ -35,14 +35,13 @@ impl SignalProcessor {
|
|||||||
|
|
||||||
/// Clean entry/exit signals to ensure proper alternation.
|
/// Clean entry/exit signals to ensure proper alternation.
|
||||||
///
|
///
|
||||||
/// Rules (matching VectorBT behavior):
|
/// Rules:
|
||||||
/// 1. First signal must be an entry
|
/// 1. First signal must be an entry
|
||||||
/// 2. After an entry, ignore further entries (unless pyramiding)
|
/// 2. After an entry, ignore further entries (unless pyramiding)
|
||||||
/// 3. After an exit, ignore further exits
|
/// 3. After an exit, ignore further exits
|
||||||
/// 4. Entries and exits must alternate properly
|
/// 4. Entries and exits must alternate properly
|
||||||
/// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar
|
/// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar
|
||||||
/// when in position, VectorBT stays in position (ignores the exit).
|
/// when in position, entry takes priority — stay in position (ignore the exit).
|
||||||
/// This matches VectorBT's "entry takes priority" behavior.
|
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `entries` - Raw entry signals
|
/// * `entries` - Raw entry signals
|
||||||
@@ -75,8 +74,7 @@ impl SignalProcessor {
|
|||||||
// Ignore exits when not in position
|
// Ignore exits when not in position
|
||||||
} else {
|
} else {
|
||||||
// In position - looking for exit (or pyramid entry)
|
// In position - looking for exit (or pyramid entry)
|
||||||
// VectorBT behavior: If both entry and exit are True, stay in position
|
// Same-bar conflict: entry takes priority — stay in position
|
||||||
// (entry signal "cancels" the exit signal)
|
|
||||||
if exits[i] && !entries[i] {
|
if exits[i] && !entries[i] {
|
||||||
// Only exit if there's no conflicting entry signal
|
// Only exit if there's no conflicting entry signal
|
||||||
clean_exits[i] = true;
|
clean_exits[i] = true;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! Tick-level signal generation for momentum entry/exit.
|
||||||
|
//!
|
||||||
|
//! Converts precomputed feature arrays (one scalar per tick) into entry and
|
||||||
|
//! exit boolean arrays that can be fed directly into `run_tick_backtest`.
|
||||||
|
//!
|
||||||
|
//! All functions are O(N) single-pass — no backward linear search, no nested
|
||||||
|
//! loops. The return_1m feature array must be precomputed by the caller
|
||||||
|
//! (via `tick_features::return_window` or equivalent).
|
||||||
|
|
||||||
|
/// Generate momentum entry signals from per-tick feature arrays.
|
||||||
|
///
|
||||||
|
/// All input slices must have the same length N.
|
||||||
|
///
|
||||||
|
/// Rules applied in order (a failing rule sets entry[i] = false):
|
||||||
|
/// 1. spread gate: `spread_pct[i] <= spread_pct_max`
|
||||||
|
/// 2. BSI gate: if `bsi_min > 0.0`, `bsi_delta[i] >= bsi_min`
|
||||||
|
/// 3. return gate: if `return_1m_min_abs > 0.0`, direction-aligned
|
||||||
|
/// `return_1m[i]` must have `abs >= return_1m_min_abs` and correct sign.
|
||||||
|
/// NaN return_1m always fails the gate.
|
||||||
|
/// 4. cooldown: after each entry, suppress the next `cooldown_ticks` ticks.
|
||||||
|
///
|
||||||
|
/// `return_direction`: +1 for long (return_1m must be positive), -1 for short
|
||||||
|
/// (return_1m must be negative).
|
||||||
|
pub fn tick_momentum_entry(
|
||||||
|
spread_pct: &[f64],
|
||||||
|
bsi_delta: &[f64],
|
||||||
|
return_1m: &[f64],
|
||||||
|
spread_pct_max: f64,
|
||||||
|
bsi_min: f64,
|
||||||
|
return_1m_min_abs: f64,
|
||||||
|
return_direction: i8,
|
||||||
|
cooldown_ticks: usize,
|
||||||
|
) -> Vec<bool> {
|
||||||
|
let n = spread_pct.len();
|
||||||
|
let mut entries = vec![false; n];
|
||||||
|
let mut cooldown_until: usize = 0;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
if i < cooldown_until {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spread gate
|
||||||
|
if spread_pct[i] > spread_pct_max {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BSI delta gate (disabled when bsi_min == 0.0)
|
||||||
|
if bsi_min > 0.0 {
|
||||||
|
let b = if i < bsi_delta.len() { bsi_delta[i] } else { continue };
|
||||||
|
if b < bsi_min {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-minute return gate (disabled when return_1m_min_abs == 0.0)
|
||||||
|
if return_1m_min_abs > 0.0 {
|
||||||
|
let r = if i < return_1m.len() { return_1m[i] } else { continue };
|
||||||
|
if r.is_nan() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let abs_r = r.abs();
|
||||||
|
if abs_r < return_1m_min_abs {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Direction alignment: long needs positive return, short needs negative
|
||||||
|
if return_direction > 0 && r < 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if return_direction < 0 && r > 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[i] = true;
|
||||||
|
cooldown_until = i + 1 + cooldown_ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate time-based exit signals (EOD / session-end).
|
||||||
|
///
|
||||||
|
/// Sets exit[i] = true for every tick at or after `eod_exit_time_ns`.
|
||||||
|
/// When `eod_exit_time_ns == 0` all exits are false (disabled).
|
||||||
|
///
|
||||||
|
/// `timestamps_ns`: nanoseconds-since-epoch timestamp for each tick.
|
||||||
|
pub fn tick_momentum_exit(timestamps_ns: &[i64], eod_exit_time_ns: i64) -> Vec<bool> {
|
||||||
|
let n = timestamps_ns.len();
|
||||||
|
if eod_exit_time_ns == 0 {
|
||||||
|
return vec![false; n];
|
||||||
|
}
|
||||||
|
timestamps_ns
|
||||||
|
.iter()
|
||||||
|
.map(|&ts| ts >= eod_exit_time_ns)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_return_1m(vals: &[f64]) -> Vec<f64> {
|
||||||
|
vals.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entry_spread_gate() {
|
||||||
|
// All spreads above max → no entries
|
||||||
|
let spread = vec![3.0, 4.0, 6.0];
|
||||||
|
let bsi = vec![0.6, 0.7, 0.8];
|
||||||
|
let ret = vec![1.0, 1.0, 1.0];
|
||||||
|
let entries = tick_momentum_entry(&spread, &bsi, &ret, 2.0, 0.0, 0.0, 1, 0);
|
||||||
|
assert_eq!(entries, vec![false, false, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entry_bsi_gate() {
|
||||||
|
let spread = vec![1.0, 1.0, 1.0];
|
||||||
|
let bsi = vec![0.3, 0.6, 0.4]; // only index 1 passes bsi_min=0.5
|
||||||
|
let ret = vec![0.5, 0.5, 0.5];
|
||||||
|
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.5, 0.0, 1, 0);
|
||||||
|
assert_eq!(entries, vec![false, true, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entry_return_gate_long() {
|
||||||
|
let spread = vec![1.0, 1.0, 1.0, 1.0];
|
||||||
|
let bsi = vec![0.6, 0.6, 0.6, 0.6];
|
||||||
|
// positive, positive, too small, negative
|
||||||
|
let ret = vec![0.5, 1.0, 0.1, -0.5];
|
||||||
|
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, 1, 0);
|
||||||
|
assert_eq!(entries, vec![true, true, false, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entry_return_gate_short() {
|
||||||
|
let spread = vec![1.0, 1.0, 1.0];
|
||||||
|
let bsi = vec![0.6, 0.6, 0.6];
|
||||||
|
// negative enough, positive (fails direction), nan
|
||||||
|
let ret = vec![-0.5, 0.5, f64::NAN];
|
||||||
|
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, -1, 0);
|
||||||
|
assert_eq!(entries, vec![true, false, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_entry_cooldown() {
|
||||||
|
// cooldown_ticks=2: after entry at i=0, next eligible at i=3
|
||||||
|
let spread = vec![1.0; 6];
|
||||||
|
let bsi = vec![0.6; 6];
|
||||||
|
let ret = vec![0.0; 6];
|
||||||
|
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.0, 1, 2);
|
||||||
|
assert!(entries[0]);
|
||||||
|
assert!(!entries[1]);
|
||||||
|
assert!(!entries[2]);
|
||||||
|
assert!(entries[3]);
|
||||||
|
assert!(!entries[4]);
|
||||||
|
assert!(!entries[5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exit_disabled() {
|
||||||
|
let ts = vec![1_000_000_i64, 2_000_000, 3_000_000];
|
||||||
|
let exits = tick_momentum_exit(&ts, 0);
|
||||||
|
assert_eq!(exits, vec![false, false, false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exit_eod_fires() {
|
||||||
|
let ts = vec![1_000_i64, 2_000, 3_000, 4_000];
|
||||||
|
let exits = tick_momentum_exit(&ts, 3_000);
|
||||||
|
assert_eq!(exits, vec![false, false, true, true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod options;
|
|||||||
pub mod pairs;
|
pub mod pairs;
|
||||||
pub mod single;
|
pub mod single;
|
||||||
pub mod spreads;
|
pub mod spreads;
|
||||||
|
pub mod tick;
|
||||||
|
|
||||||
pub use basket::BasketBacktest;
|
pub use basket::BasketBacktest;
|
||||||
pub use multi::MultiStrategyBacktest;
|
pub use multi::MultiStrategyBacktest;
|
||||||
@@ -15,3 +16,4 @@ pub use single::SingleBacktest;
|
|||||||
pub use spreads::{
|
pub use spreads::{
|
||||||
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
||||||
};
|
};
|
||||||
|
pub use tick::{TickBacktest, TickBacktestConfig};
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
//! Tick-level backtest implementation.
|
||||||
|
//!
|
||||||
|
//! Accepts raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas) plus
|
||||||
|
//! parallel entry/exit signal arrays, then simulates each trade to
|
||||||
|
//! stop-loss / take-profit / max-hold-time exit at full tick resolution.
|
||||||
|
//!
|
||||||
|
//! This is the right path for intraday options momentum strategies where the
|
||||||
|
//! exact fill tick matters. Do not resample to bars before calling this —
|
||||||
|
//! bar resampling discards intra-bar path information and makes scalping
|
||||||
|
//! strategies unbacktestable.
|
||||||
|
|
||||||
|
use crate::core::types::{
|
||||||
|
BacktestConfig, BacktestMetrics, BacktestResult, ExitReason, Price, TickData, Timestamp, Trade,
|
||||||
|
};
|
||||||
|
use crate::portfolio::engine::compute_backtest_metrics;
|
||||||
|
|
||||||
|
/// Configuration specific to tick backtests.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TickBacktestConfig {
|
||||||
|
/// Shared execution config (capital, fees, slippage).
|
||||||
|
pub base: BacktestConfig,
|
||||||
|
/// Stop-loss as percentage of entry price (e.g. 5.0 = 5%).
|
||||||
|
pub stop_loss_pct: f64,
|
||||||
|
/// Take-profit as percentage of entry price (e.g. 10.0 = 10%).
|
||||||
|
pub take_profit_pct: f64,
|
||||||
|
/// Maximum hold time in seconds. 0 = no time limit.
|
||||||
|
pub max_hold_seconds: u64,
|
||||||
|
/// Minimum ticks between entries (cooldown). Prevents overlapping positions.
|
||||||
|
pub entry_cooldown_ticks: usize,
|
||||||
|
/// Maximum trades to simulate (bounds runtime for large windows).
|
||||||
|
pub max_trades: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TickBacktestConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base: BacktestConfig::default(),
|
||||||
|
stop_loss_pct: 5.0,
|
||||||
|
take_profit_pct: 10.0,
|
||||||
|
max_hold_seconds: 1800,
|
||||||
|
entry_cooldown_ticks: 10,
|
||||||
|
max_trades: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick-level backtest runner.
|
||||||
|
pub struct TickBacktest {
|
||||||
|
config: TickBacktestConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TickBacktest {
|
||||||
|
pub fn new(config: TickBacktestConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the tick backtest.
|
||||||
|
///
|
||||||
|
/// `ticks` — raw tick data (ltp, bid, ask, per-tick qty deltas)
|
||||||
|
/// `entries` — parallel bool array: true at ticks where a new long entry is allowed
|
||||||
|
/// `exits` — parallel bool array: true at ticks where an open position must close
|
||||||
|
/// `symbol` — instrument label used in trade records
|
||||||
|
pub fn run(
|
||||||
|
&self,
|
||||||
|
ticks: &TickData,
|
||||||
|
entries: &[bool],
|
||||||
|
exits: &[bool],
|
||||||
|
symbol: &str,
|
||||||
|
) -> BacktestResult {
|
||||||
|
let n = ticks.len();
|
||||||
|
assert_eq!(n, entries.len(), "ticks and entries must have same length");
|
||||||
|
assert_eq!(n, exits.len(), "ticks and exits must have same length");
|
||||||
|
|
||||||
|
let slippage_frac = self.config.base.slippage; // e.g. 0.0005 = 0.05%
|
||||||
|
let fee_frac = self.config.base.fees; // e.g. 0.001 = 0.1%
|
||||||
|
let stop_frac = self.config.stop_loss_pct / 100.0;
|
||||||
|
let target_frac = self.config.take_profit_pct / 100.0;
|
||||||
|
let max_hold_ns: i64 = self.config.max_hold_seconds as i64 * 1_000_000_000;
|
||||||
|
|
||||||
|
let mut trades: Vec<Trade> = Vec::new();
|
||||||
|
let mut trade_id: u64 = 0;
|
||||||
|
|
||||||
|
// Position state
|
||||||
|
let mut in_position = false;
|
||||||
|
let mut entry_idx: usize = 0;
|
||||||
|
let mut entry_price: Price = 0.0;
|
||||||
|
let mut entry_time: Timestamp = 0;
|
||||||
|
let mut stop_level: Price = 0.0;
|
||||||
|
let mut target_level: Price = 0.0;
|
||||||
|
let mut entry_fees: f64 = 0.0;
|
||||||
|
let mut cooldown_until: usize = 0;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let ltp = ticks.ltp[i];
|
||||||
|
let bid = if ticks.bid[i] > 0.0 { ticks.bid[i] } else { ltp };
|
||||||
|
let ask = if ticks.ask[i] > 0.0 { ticks.ask[i] } else { ltp };
|
||||||
|
let ts = ticks.timestamps[i];
|
||||||
|
|
||||||
|
if in_position {
|
||||||
|
// Check time exit first (hard deadline)
|
||||||
|
let time_exit = max_hold_ns > 0 && (ts - entry_time) >= max_hold_ns;
|
||||||
|
|
||||||
|
// Check explicit exit signal
|
||||||
|
let signal_exit = exits[i];
|
||||||
|
|
||||||
|
// Check stop and target against ltp (tick-exact, no OHLC lookahead)
|
||||||
|
let stop_hit = ltp <= stop_level;
|
||||||
|
let target_hit = ltp >= target_level;
|
||||||
|
|
||||||
|
let (exit_price, reason) = if stop_hit {
|
||||||
|
// Fill at stop level (not ltp — avoid worse-than-stop fills)
|
||||||
|
let fill = stop_level * (1.0 - slippage_frac);
|
||||||
|
(fill, ExitReason::StopLoss)
|
||||||
|
} else if target_hit {
|
||||||
|
let fill = target_level * (1.0 - slippage_frac);
|
||||||
|
(fill, ExitReason::TakeProfit)
|
||||||
|
} else if time_exit || signal_exit {
|
||||||
|
let fill = bid * (1.0 - slippage_frac);
|
||||||
|
let reason = if time_exit { ExitReason::TimeExit } else { ExitReason::Signal };
|
||||||
|
(fill, reason)
|
||||||
|
} else if i == n - 1 {
|
||||||
|
// End of data — force close at bid
|
||||||
|
let fill = bid * (1.0 - slippage_frac);
|
||||||
|
(fill, ExitReason::EndOfData)
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let exit_fees = exit_price * fee_frac;
|
||||||
|
let gross_pnl = (exit_price - entry_price) * 1.0; // qty=1; caller scales by lot_size
|
||||||
|
let net_pnl = gross_pnl - entry_fees - exit_fees;
|
||||||
|
let return_pct = net_pnl / entry_price * 100.0;
|
||||||
|
|
||||||
|
trades.push(Trade {
|
||||||
|
id: trade_id,
|
||||||
|
symbol: symbol.to_string(),
|
||||||
|
entry_idx,
|
||||||
|
exit_idx: i,
|
||||||
|
entry_price,
|
||||||
|
exit_price,
|
||||||
|
size: 1.0,
|
||||||
|
direction: crate::core::types::Direction::Long,
|
||||||
|
pnl: net_pnl,
|
||||||
|
return_pct,
|
||||||
|
entry_time,
|
||||||
|
exit_time: ts,
|
||||||
|
fees: entry_fees + exit_fees,
|
||||||
|
exit_reason: reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
trade_id += 1;
|
||||||
|
in_position = false;
|
||||||
|
cooldown_until = i + self.config.entry_cooldown_ticks;
|
||||||
|
|
||||||
|
if trades.len() >= self.config.max_trades {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not in position — check for entry
|
||||||
|
if i < cooldown_until {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !entries[i] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ask <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry_price = ask * (1.0 + slippage_frac);
|
||||||
|
entry_fees = entry_price * fee_frac;
|
||||||
|
entry_idx = i;
|
||||||
|
entry_time = ts;
|
||||||
|
stop_level = entry_price * (1.0 - stop_frac);
|
||||||
|
target_level = entry_price * (1.0 + target_frac);
|
||||||
|
in_position = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::build_result(trades, self.config.base.initial_capital, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_result(trades: Vec<Trade>, initial_capital: f64, _symbol: &str) -> BacktestResult {
|
||||||
|
if trades.is_empty() {
|
||||||
|
let metrics = BacktestMetrics {
|
||||||
|
start_value: initial_capital,
|
||||||
|
end_value: initial_capital,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
return BacktestResult::new(metrics, vec![initial_capital], vec![0.0], vec![], vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-trade equity and return curves (one point per trade close).
|
||||||
|
let mut equity = initial_capital;
|
||||||
|
let mut equity_curve = vec![initial_capital];
|
||||||
|
let mut returns = Vec::with_capacity(trades.len());
|
||||||
|
|
||||||
|
for t in &trades {
|
||||||
|
let prev = *equity_curve.last().unwrap();
|
||||||
|
equity += t.pnl;
|
||||||
|
equity_curve.push(equity);
|
||||||
|
let ret = if prev > 0.0 { (equity - prev) / prev } else { 0.0 };
|
||||||
|
returns.push(ret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawdown curve over equity points (percentage, positive = drawdown).
|
||||||
|
let mut peak = initial_capital;
|
||||||
|
let drawdown_curve: Vec<f64> = equity_curve
|
||||||
|
.iter()
|
||||||
|
.map(|&e| {
|
||||||
|
if e > peak {
|
||||||
|
peak = e;
|
||||||
|
}
|
||||||
|
if peak > 0.0 { (peak - e) / peak * 100.0 } else { 0.0 }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let metrics =
|
||||||
|
compute_backtest_metrics(&equity_curve, &drawdown_curve, &returns, &trades, initial_capital);
|
||||||
|
|
||||||
|
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::types::BacktestConfig;
|
||||||
|
|
||||||
|
fn make_ticks(n: usize, base_price: f64, trend: f64) -> TickData {
|
||||||
|
let ltp: Vec<f64> = (0..n).map(|i| base_price + i as f64 * trend).collect();
|
||||||
|
let bid: Vec<f64> = ltp.iter().map(|p| p - 0.5).collect();
|
||||||
|
let ask: Vec<f64> = ltp.iter().map(|p| p + 0.5).collect();
|
||||||
|
TickData {
|
||||||
|
timestamps: (0..n as i64).map(|i| i * 1_000_000_000).collect(), // 1s apart
|
||||||
|
ltp,
|
||||||
|
bid,
|
||||||
|
ask,
|
||||||
|
buy_qty_delta: vec![100.0; n],
|
||||||
|
sell_qty_delta: vec![80.0; n],
|
||||||
|
oi: vec![0.0; n],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_target_hit() {
|
||||||
|
// 100 ticks trending up — entry at tick 0, target should be hit
|
||||||
|
let ticks = make_ticks(100, 100.0, 0.5); // price goes 100 → 149.5
|
||||||
|
let mut entries = vec![false; 100];
|
||||||
|
entries[0] = true;
|
||||||
|
let exits = vec![false; 100];
|
||||||
|
|
||||||
|
let config = TickBacktestConfig {
|
||||||
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
||||||
|
stop_loss_pct: 5.0,
|
||||||
|
take_profit_pct: 10.0,
|
||||||
|
max_hold_seconds: 0, // no time limit
|
||||||
|
entry_cooldown_ticks: 5,
|
||||||
|
max_trades: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bt = TickBacktest::new(config);
|
||||||
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
||||||
|
|
||||||
|
assert_eq!(result.trades.len(), 1);
|
||||||
|
assert_eq!(result.trades[0].exit_reason, ExitReason::TakeProfit);
|
||||||
|
assert!(result.trades[0].pnl > 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_hit() {
|
||||||
|
// 100 ticks trending down — entry at tick 0, stop should be hit
|
||||||
|
let ticks = make_ticks(100, 100.0, -0.5); // price goes 100 → 50.5
|
||||||
|
let mut entries = vec![false; 100];
|
||||||
|
entries[0] = true;
|
||||||
|
let exits = vec![false; 100];
|
||||||
|
|
||||||
|
let config = TickBacktestConfig {
|
||||||
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
||||||
|
stop_loss_pct: 5.0,
|
||||||
|
take_profit_pct: 20.0,
|
||||||
|
max_hold_seconds: 0,
|
||||||
|
entry_cooldown_ticks: 5,
|
||||||
|
max_trades: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bt = TickBacktest::new(config);
|
||||||
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
||||||
|
|
||||||
|
assert_eq!(result.trades.len(), 1);
|
||||||
|
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
|
||||||
|
assert!(result.trades[0].pnl < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_time_exit() {
|
||||||
|
// Flat price — neither stop nor target hit, time exit should fire
|
||||||
|
let ticks = make_ticks(200, 100.0, 0.0);
|
||||||
|
let mut entries = vec![false; 200];
|
||||||
|
entries[0] = true;
|
||||||
|
let exits = vec![false; 200];
|
||||||
|
|
||||||
|
let config = TickBacktestConfig {
|
||||||
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
||||||
|
stop_loss_pct: 50.0, // very wide, won't hit
|
||||||
|
take_profit_pct: 50.0,
|
||||||
|
max_hold_seconds: 10, // 10 ticks at 1s each
|
||||||
|
entry_cooldown_ticks: 5,
|
||||||
|
max_trades: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bt = TickBacktest::new(config);
|
||||||
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
||||||
|
|
||||||
|
assert_eq!(result.trades.len(), 1);
|
||||||
|
assert_eq!(result.trades[0].exit_reason, ExitReason::TimeExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_trades_with_cooldown() {
|
||||||
|
let ticks = make_ticks(200, 100.0, 0.2);
|
||||||
|
// Entry every 20 ticks
|
||||||
|
let entries: Vec<bool> = (0..200).map(|i| i % 20 == 0).collect();
|
||||||
|
let exits = vec![false; 200];
|
||||||
|
|
||||||
|
let config = TickBacktestConfig {
|
||||||
|
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
|
||||||
|
stop_loss_pct: 5.0,
|
||||||
|
take_profit_pct: 10.0,
|
||||||
|
max_hold_seconds: 0,
|
||||||
|
entry_cooldown_ticks: 5,
|
||||||
|
max_trades: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bt = TickBacktest::new(config);
|
||||||
|
let result = bt.run(&ticks, &entries, &exits, "TEST");
|
||||||
|
|
||||||
|
assert!(result.trades.len() > 1);
|
||||||
|
assert!(result.metrics.total_trades > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_ticks_returns_empty_result() {
|
||||||
|
let ticks = TickData {
|
||||||
|
timestamps: vec![],
|
||||||
|
ltp: vec![],
|
||||||
|
bid: vec![],
|
||||||
|
ask: vec![],
|
||||||
|
buy_qty_delta: vec![],
|
||||||
|
sell_qty_delta: vec![],
|
||||||
|
oi: vec![],
|
||||||
|
};
|
||||||
|
let config = TickBacktestConfig::default();
|
||||||
|
let bt = TickBacktest::new(config);
|
||||||
|
let result = bt.run(&ticks, &[], &[], "TEST");
|
||||||
|
assert_eq!(result.trades.len(), 0);
|
||||||
|
assert_eq!(result.metrics.total_trades, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user