Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
335b3c3b68 | ||
|
|
83eac3aa80 | ||
|
|
6d1b99fc05 | ||
|
|
fc0c756203 | ||
|
|
3a9f7564ad | ||
|
|
fb3a2dda25 | ||
|
|
3420edee2b | ||
|
|
fa6959bb99 | ||
|
|
514c235f1c | ||
|
|
7e91293e1a | ||
|
|
c9451069d8 | ||
|
|
997e234a85 | ||
|
|
e049a2f968 | ||
|
|
f7592d5d79 | ||
|
|
87545683eb | ||
|
|
eb5335e809 | ||
|
|
0ff67e7fe2 | ||
|
|
ab568cc9fb | ||
|
|
4d4ea2e5e9 | ||
|
|
a82ddc598a | ||
|
|
bb6ce05c57 | ||
|
|
aad09da117 | ||
|
|
c711e9ce8e | ||
|
|
4d0d4cbfa3 | ||
|
|
03ac0192b7 | ||
|
|
baa87b476b | ||
|
|
f4a40617c0 | ||
|
|
196f63f5c3 | ||
|
|
5ca413ebbf | ||
|
|
ca2965aade |
@@ -28,7 +28,7 @@ jobs:
|
|||||||
uses: PyO3/maturin-action@v1
|
uses: PyO3/maturin-action@v1
|
||||||
with:
|
with:
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
args: --release --out dist
|
args: --release --out dist -i python3.10 -i python3.11 -i python3.12
|
||||||
manylinux: auto
|
manylinux: auto
|
||||||
|
|
||||||
- name: Upload wheels
|
- name: Upload wheels
|
||||||
@@ -46,6 +46,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Build wheels
|
- name: Build wheels
|
||||||
uses: PyO3/maturin-action@v1
|
uses: PyO3/maturin-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -67,6 +72,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Build wheels
|
- name: Build wheels
|
||||||
uses: PyO3/maturin-action@v1
|
uses: PyO3/maturin-action@v1
|
||||||
with:
|
with:
|
||||||
|
|||||||
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"approx",
|
"approx",
|
||||||
"criterion",
|
"criterion",
|
||||||
|
|||||||
+8
-3
@@ -1,10 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.1.0"
|
version = "0.4.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "High-performance Rust backtesting engine for Quant5"
|
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
|
||||||
authors = ["Quant5 team"]
|
authors = ["Alphabench <contact@alphabench.in>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
repository = "https://github.com/alphabench/raptorbt"
|
||||||
|
homepage = "https://www.alphabench.in/raptorbt"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["backtesting", "trading", "quantitative-finance", "rust", "python"]
|
||||||
|
categories = ["finance", "simulation"]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 Quant5 team
|
Copyright (c) 2024 Alphabench
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+5
-5
@@ -4,13 +4,13 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "raptorbt"
|
name = "raptorbt"
|
||||||
version = "0.1.0"
|
version = "0.4.1"
|
||||||
description = "High-performance Rust backtesting engine with Python bindings"
|
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"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Quant5 team"}
|
{name = "Alphabench", email = "contact@alphabench.in"}
|
||||||
]
|
]
|
||||||
keywords = [
|
keywords = [
|
||||||
"backtesting",
|
"backtesting",
|
||||||
@@ -39,9 +39,9 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/alphabench/raptorbt"
|
Homepage = "https://www.alphabench.in/raptorbt"
|
||||||
Repository = "https://github.com/alphabench/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"
|
"Bug Tracker" = "https://github.com/alphabench/raptorbt/issues"
|
||||||
|
|
||||||
[tool.maturin]
|
[tool.maturin]
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
RaptorBT - High-performance Rust backtesting engine for Quant5.
|
RaptorBT - High-performance Rust backtesting engine.
|
||||||
|
|
||||||
This module provides Python bindings for the Rust-based backtesting engine,
|
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 (
|
||||||
# Config classes
|
# Config classes
|
||||||
PyBacktestConfig,
|
PyBacktestConfig,
|
||||||
|
PyInstrumentConfig,
|
||||||
PyStopConfig,
|
PyStopConfig,
|
||||||
PyTargetConfig,
|
PyTargetConfig,
|
||||||
# Result classes
|
# Result classes
|
||||||
@@ -24,6 +26,23 @@ from raptorbt._raptorbt import (
|
|||||||
run_options_backtest,
|
run_options_backtest,
|
||||||
run_pairs_backtest,
|
run_pairs_backtest,
|
||||||
run_multi_backtest,
|
run_multi_backtest,
|
||||||
|
run_spread_backtest,
|
||||||
|
run_tick_backtest,
|
||||||
|
# Batch backtest
|
||||||
|
PyBatchSpreadItem,
|
||||||
|
batch_spread_backtest,
|
||||||
|
# Monte Carlo simulation
|
||||||
|
simulate_portfolio_mc,
|
||||||
|
# Tick signal functions
|
||||||
|
compute_tick_entry_signals,
|
||||||
|
compute_tick_exit_signals,
|
||||||
|
# Tick feature functions
|
||||||
|
tick_spread_pct,
|
||||||
|
buy_sell_imbalance_delta,
|
||||||
|
return_window,
|
||||||
|
realized_vol_rolling,
|
||||||
|
oi_position_pct,
|
||||||
|
tick_velocity,
|
||||||
# Indicator functions
|
# Indicator functions
|
||||||
sma,
|
sma,
|
||||||
ema,
|
ema,
|
||||||
@@ -35,13 +54,16 @@ from raptorbt._raptorbt import (
|
|||||||
adx,
|
adx,
|
||||||
vwap,
|
vwap,
|
||||||
supertrend,
|
supertrend,
|
||||||
|
rolling_min,
|
||||||
|
rolling_max,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.4.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config classes
|
# Config classes
|
||||||
"PyBacktestConfig",
|
"PyBacktestConfig",
|
||||||
|
"PyInstrumentConfig",
|
||||||
"PyStopConfig",
|
"PyStopConfig",
|
||||||
"PyTargetConfig",
|
"PyTargetConfig",
|
||||||
# Result classes
|
# Result classes
|
||||||
@@ -54,6 +76,23 @@ __all__ = [
|
|||||||
"run_options_backtest",
|
"run_options_backtest",
|
||||||
"run_pairs_backtest",
|
"run_pairs_backtest",
|
||||||
"run_multi_backtest",
|
"run_multi_backtest",
|
||||||
|
"run_spread_backtest",
|
||||||
|
"run_tick_backtest",
|
||||||
|
# Batch backtest
|
||||||
|
"PyBatchSpreadItem",
|
||||||
|
"batch_spread_backtest",
|
||||||
|
# Monte Carlo simulation
|
||||||
|
"simulate_portfolio_mc",
|
||||||
|
# Tick signal functions
|
||||||
|
"compute_tick_entry_signals",
|
||||||
|
"compute_tick_exit_signals",
|
||||||
|
# Tick feature functions
|
||||||
|
"tick_spread_pct",
|
||||||
|
"buy_sell_imbalance_delta",
|
||||||
|
"return_window",
|
||||||
|
"realized_vol_rolling",
|
||||||
|
"oi_position_pct",
|
||||||
|
"tick_velocity",
|
||||||
# Indicator functions
|
# Indicator functions
|
||||||
"sma",
|
"sma",
|
||||||
"ema",
|
"ema",
|
||||||
@@ -65,4 +104,6 @@ __all__ = [
|
|||||||
"adx",
|
"adx",
|
||||||
"vwap",
|
"vwap",
|
||||||
"supertrend",
|
"supertrend",
|
||||||
|
"rolling_min",
|
||||||
|
"rolling_max",
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,11 @@
|
|||||||
//! Core types and utilities for RaptorBT.
|
//! Core types and utilities for RaptorBT.
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod session;
|
||||||
pub mod timeseries;
|
pub mod timeseries;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use error::{RaptorError, Result};
|
pub use error::{RaptorError, Result};
|
||||||
|
pub use session::{SessionConfig, SessionTracker};
|
||||||
pub use timeseries::TimeSeries;
|
pub use timeseries::TimeSeries;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! Session tracking for intraday strategies.
|
||||||
|
//!
|
||||||
|
//! Handles:
|
||||||
|
//! - Session boundary detection (market open/close)
|
||||||
|
//! - Squareoff time enforcement
|
||||||
|
//! - Session high/low tracking for ORB and session-based indicators
|
||||||
|
//! - Timezone handling for IST (India Standard Time)
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Session configuration for trading hours.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionConfig {
|
||||||
|
/// Market open hour (24-hour format).
|
||||||
|
pub market_open_hour: u32,
|
||||||
|
/// Market open minute.
|
||||||
|
pub market_open_minute: u32,
|
||||||
|
/// Market close hour (24-hour format).
|
||||||
|
pub market_close_hour: u32,
|
||||||
|
/// Market close minute.
|
||||||
|
pub market_close_minute: u32,
|
||||||
|
/// Squareoff minutes before market close.
|
||||||
|
pub squareoff_minutes_before_close: u32,
|
||||||
|
/// Timezone offset in hours from UTC (5 for IST = UTC+5:30).
|
||||||
|
pub timezone_offset_hours: i32,
|
||||||
|
/// Timezone offset minutes (30 for IST).
|
||||||
|
pub timezone_offset_minutes: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25)
|
||||||
|
Self {
|
||||||
|
market_open_hour: 9,
|
||||||
|
market_open_minute: 15,
|
||||||
|
market_close_hour: 15,
|
||||||
|
market_close_minute: 30,
|
||||||
|
squareoff_minutes_before_close: 5,
|
||||||
|
timezone_offset_hours: 5,
|
||||||
|
timezone_offset_minutes: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionConfig {
|
||||||
|
/// Create NSE equity session config (9:15 - 15:30).
|
||||||
|
pub fn nse_equity() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create MCX commodity session config (9:00 - 23:30).
|
||||||
|
pub fn mcx_commodity() -> Self {
|
||||||
|
Self {
|
||||||
|
market_open_hour: 9,
|
||||||
|
market_open_minute: 0,
|
||||||
|
market_close_hour: 23,
|
||||||
|
market_close_minute: 30,
|
||||||
|
squareoff_minutes_before_close: 5,
|
||||||
|
timezone_offset_hours: 5,
|
||||||
|
timezone_offset_minutes: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create CDS currency session config (9:00 - 17:00).
|
||||||
|
pub fn cds_currency() -> Self {
|
||||||
|
Self {
|
||||||
|
market_open_hour: 9,
|
||||||
|
market_open_minute: 0,
|
||||||
|
market_close_hour: 17,
|
||||||
|
market_close_minute: 0,
|
||||||
|
squareoff_minutes_before_close: 5,
|
||||||
|
timezone_offset_hours: 5,
|
||||||
|
timezone_offset_minutes: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get market open time in minutes from midnight.
|
||||||
|
pub fn market_open_minutes(&self) -> u32 {
|
||||||
|
self.market_open_hour * 60 + self.market_open_minute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get market close time in minutes from midnight.
|
||||||
|
pub fn market_close_minutes(&self) -> u32 {
|
||||||
|
self.market_close_hour * 60 + self.market_close_minute
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get squareoff time in minutes from midnight.
|
||||||
|
pub fn squareoff_minutes(&self) -> u32 {
|
||||||
|
self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get timezone offset in seconds.
|
||||||
|
pub fn timezone_offset_seconds(&self) -> i64 {
|
||||||
|
(self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session tracker for managing intraday session state.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionTracker {
|
||||||
|
config: SessionConfig,
|
||||||
|
/// Current session date (days since epoch in local timezone).
|
||||||
|
current_session_date: i64,
|
||||||
|
/// Session high price.
|
||||||
|
session_high: f64,
|
||||||
|
/// Session low price.
|
||||||
|
session_low: f64,
|
||||||
|
/// Session open price.
|
||||||
|
session_open: f64,
|
||||||
|
/// Bar index at session start.
|
||||||
|
session_start_idx: usize,
|
||||||
|
/// Whether we're currently in a trading session.
|
||||||
|
in_session: bool,
|
||||||
|
/// Whether squareoff has been triggered today.
|
||||||
|
squareoff_triggered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionTracker {
|
||||||
|
/// Create a new session tracker.
|
||||||
|
pub fn new(config: SessionConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
current_session_date: -1,
|
||||||
|
session_high: f64::NEG_INFINITY,
|
||||||
|
session_low: f64::INFINITY,
|
||||||
|
session_open: 0.0,
|
||||||
|
session_start_idx: 0,
|
||||||
|
in_session: false,
|
||||||
|
squareoff_triggered: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert nanosecond timestamp to local time components.
|
||||||
|
fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) {
|
||||||
|
// Convert to seconds
|
||||||
|
let timestamp_s = timestamp_ns / 1_000_000_000;
|
||||||
|
|
||||||
|
// Apply timezone offset
|
||||||
|
let local_s = timestamp_s + self.config.timezone_offset_seconds();
|
||||||
|
|
||||||
|
// Calculate date (days since epoch)
|
||||||
|
let days = local_s / 86400;
|
||||||
|
|
||||||
|
// Calculate time within day
|
||||||
|
let time_in_day = (local_s % 86400) as u32;
|
||||||
|
let hours = time_in_day / 3600;
|
||||||
|
let minutes = (time_in_day % 3600) / 60;
|
||||||
|
let seconds = time_in_day % 60;
|
||||||
|
|
||||||
|
(days, hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get minutes from midnight for a timestamp.
|
||||||
|
fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 {
|
||||||
|
let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
|
||||||
|
hours * 60 + minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if timestamp is within trading hours.
|
||||||
|
pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool {
|
||||||
|
let minutes = self.get_minutes_from_midnight(timestamp_ns);
|
||||||
|
minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if it's squareoff time.
|
||||||
|
pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool {
|
||||||
|
let minutes = self.get_minutes_from_midnight(timestamp_ns);
|
||||||
|
minutes >= self.config.squareoff_minutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this bar starts a new session.
|
||||||
|
pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool {
|
||||||
|
let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns);
|
||||||
|
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
|
||||||
|
|
||||||
|
// New day
|
||||||
|
if curr_date != prev_date {
|
||||||
|
let curr_minutes = curr_h * 60 + curr_m;
|
||||||
|
return curr_minutes >= self.config.market_open_minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same day, but crossed market open
|
||||||
|
let prev_minutes = prev_h * 60 + prev_m;
|
||||||
|
let curr_minutes = curr_h * 60 + curr_m;
|
||||||
|
|
||||||
|
prev_minutes < self.config.market_open_minutes()
|
||||||
|
&& curr_minutes >= self.config.market_open_minutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this bar ends the session.
|
||||||
|
pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option<i64>) -> bool {
|
||||||
|
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
|
||||||
|
let curr_minutes = curr_h * 60 + curr_m;
|
||||||
|
|
||||||
|
// At or past market close
|
||||||
|
if curr_minutes >= self.config.market_close_minutes() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if next bar is in a new session
|
||||||
|
if let Some(next_ts) = next_ts_ns {
|
||||||
|
let (next_date, _, _, _) = self.timestamp_to_local(next_ts);
|
||||||
|
if next_date != curr_date {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update session state for a new bar.
|
||||||
|
///
|
||||||
|
/// Returns tuple of (is_new_session, is_squareoff_time, is_session_end).
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
idx: usize,
|
||||||
|
timestamp_ns: i64,
|
||||||
|
open: f64,
|
||||||
|
high: f64,
|
||||||
|
low: f64,
|
||||||
|
_close: f64,
|
||||||
|
prev_timestamp_ns: Option<i64>,
|
||||||
|
next_timestamp_ns: Option<i64>,
|
||||||
|
) -> (bool, bool, bool) {
|
||||||
|
let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
|
||||||
|
let time_minutes = hours * 60 + minutes;
|
||||||
|
|
||||||
|
// Check for new session
|
||||||
|
let is_new_session = if self.current_session_date != date {
|
||||||
|
// New date - check if within trading hours
|
||||||
|
if time_minutes >= self.config.market_open_minutes()
|
||||||
|
&& time_minutes < self.config.market_close_minutes()
|
||||||
|
{
|
||||||
|
self.reset_session(idx, date, open);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else if let Some(prev_ts) = prev_timestamp_ns {
|
||||||
|
if self.is_session_start(prev_ts, timestamp_ns) {
|
||||||
|
self.reset_session(idx, date, open);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First bar - start session if within hours
|
||||||
|
if time_minutes >= self.config.market_open_minutes()
|
||||||
|
&& time_minutes < self.config.market_close_minutes()
|
||||||
|
{
|
||||||
|
self.reset_session(idx, date, open);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update session high/low
|
||||||
|
if self.in_session {
|
||||||
|
if high > self.session_high {
|
||||||
|
self.session_high = high;
|
||||||
|
}
|
||||||
|
if low < self.session_low {
|
||||||
|
self.session_low = low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check squareoff time
|
||||||
|
let is_squareoff =
|
||||||
|
if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered {
|
||||||
|
self.squareoff_triggered = true;
|
||||||
|
self.in_session
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check session end
|
||||||
|
let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns);
|
||||||
|
if is_session_end {
|
||||||
|
self.in_session = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(is_new_session, is_squareoff, is_session_end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset session state for a new trading day.
|
||||||
|
fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) {
|
||||||
|
self.current_session_date = date;
|
||||||
|
self.session_start_idx = idx;
|
||||||
|
self.session_open = open_price;
|
||||||
|
self.session_high = open_price;
|
||||||
|
self.session_low = open_price;
|
||||||
|
self.in_session = true;
|
||||||
|
self.squareoff_triggered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session high.
|
||||||
|
pub fn session_high(&self) -> f64 {
|
||||||
|
self.session_high
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session low.
|
||||||
|
pub fn session_low(&self) -> f64 {
|
||||||
|
self.session_low
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session open.
|
||||||
|
pub fn session_open(&self) -> f64 {
|
||||||
|
self.session_open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get session start index.
|
||||||
|
pub fn session_start_idx(&self) -> usize {
|
||||||
|
self.session_start_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if currently in a trading session.
|
||||||
|
pub fn in_session(&self) -> bool {
|
||||||
|
self.in_session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get opening range (high - low) for the session.
|
||||||
|
pub fn opening_range(&self) -> f64 {
|
||||||
|
self.session_high - self.session_low
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
|
||||||
|
// Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds
|
||||||
|
// This is approximate for testing
|
||||||
|
let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64;
|
||||||
|
let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60;
|
||||||
|
// Subtract IST offset to get UTC
|
||||||
|
let utc_seconds = seconds - (5 * 3600 + 30 * 60);
|
||||||
|
utc_seconds * 1_000_000_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_config_defaults() {
|
||||||
|
let config = SessionConfig::default();
|
||||||
|
assert_eq!(config.market_open_hour, 9);
|
||||||
|
assert_eq!(config.market_open_minute, 15);
|
||||||
|
assert_eq!(config.market_close_hour, 15);
|
||||||
|
assert_eq!(config.market_close_minute, 30);
|
||||||
|
assert_eq!(config.squareoff_minutes_before_close, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_squareoff_minutes() {
|
||||||
|
let config = SessionConfig::default();
|
||||||
|
// 15:30 - 5 minutes = 15:25 = 925 minutes
|
||||||
|
assert_eq!(config.squareoff_minutes(), 925);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mcx_session() {
|
||||||
|
let config = SessionConfig::mcx_commodity();
|
||||||
|
assert_eq!(config.market_open_hour, 9);
|
||||||
|
assert_eq!(config.market_close_hour, 23);
|
||||||
|
assert_eq!(config.market_close_minute, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_tracker_new_session() {
|
||||||
|
let config = SessionConfig::default();
|
||||||
|
let mut tracker = SessionTracker::new(config);
|
||||||
|
|
||||||
|
// Simulate market open at 9:15 IST
|
||||||
|
let ts = make_timestamp(2024, 1, 15, 9, 15);
|
||||||
|
let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None);
|
||||||
|
|
||||||
|
assert!(is_new);
|
||||||
|
assert!(tracker.in_session());
|
||||||
|
assert_eq!(tracker.session_open(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_high_low() {
|
||||||
|
let config = SessionConfig::default();
|
||||||
|
let mut tracker = SessionTracker::new(config);
|
||||||
|
|
||||||
|
// First bar
|
||||||
|
let ts1 = make_timestamp(2024, 1, 15, 9, 15);
|
||||||
|
tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None);
|
||||||
|
|
||||||
|
// Second bar
|
||||||
|
let ts2 = make_timestamp(2024, 1, 15, 9, 30);
|
||||||
|
tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None);
|
||||||
|
|
||||||
|
assert_eq!(tracker.session_high(), 110.0);
|
||||||
|
assert_eq!(tracker.session_low(), 95.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-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 {
|
||||||
@@ -212,6 +250,10 @@ pub enum ExitReason {
|
|||||||
TrailingStop,
|
TrailingStop,
|
||||||
/// End of data.
|
/// End of data.
|
||||||
EndOfData,
|
EndOfData,
|
||||||
|
/// Option expiry settlement.
|
||||||
|
Settlement,
|
||||||
|
/// Max hold time exceeded (tick backtest).
|
||||||
|
TimeExit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backtest configuration.
|
/// Backtest configuration.
|
||||||
@@ -244,6 +286,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.
|
/// Stop-loss configuration.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
pub enum StopConfig {
|
pub enum StopConfig {
|
||||||
@@ -335,6 +418,10 @@ pub struct BacktestMetrics {
|
|||||||
pub avg_holding_period: f64,
|
pub avg_holding_period: f64,
|
||||||
/// Exposure time percentage (time in market).
|
/// Exposure time percentage (time in market).
|
||||||
pub exposure_pct: f64,
|
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.
|
/// Complete backtest result.
|
||||||
@@ -386,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,3 +547,49 @@ impl Default for Position {
|
|||||||
Self::new()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,20 @@
|
|||||||
//! and return Vec outputs. NaN values are used for the warmup period.
|
//! and return Vec outputs. NaN values are used for the warmup period.
|
||||||
|
|
||||||
pub mod momentum;
|
pub mod momentum;
|
||||||
|
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;
|
||||||
|
|
||||||
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 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,106 @@
|
|||||||
|
//! Rolling min/max indicators for LLV/HHV support.
|
||||||
|
//!
|
||||||
|
//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV)
|
||||||
|
//! and Highest High Value (HHV) expressions.
|
||||||
|
|
||||||
|
use crate::core::error::RaptorError;
|
||||||
|
|
||||||
|
/// Calculate rolling minimum (Lowest Low Value) over a period.
|
||||||
|
///
|
||||||
|
/// Returns NaN for the first (period - 1) values where insufficient data exists.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `data` - Input data slice
|
||||||
|
/// * `period` - Lookback period
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Vec of rolling minimum values
|
||||||
|
pub fn rolling_min(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(RaptorError::invalid_parameter("period must be at least 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = data.len();
|
||||||
|
let mut result = vec![f64::NAN; n];
|
||||||
|
|
||||||
|
for i in (period - 1)..n {
|
||||||
|
let start = i + 1 - period;
|
||||||
|
let min_val =
|
||||||
|
data[start..=i]
|
||||||
|
.iter()
|
||||||
|
.fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) });
|
||||||
|
result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val };
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate rolling maximum (Highest High Value) over a period.
|
||||||
|
///
|
||||||
|
/// Returns NaN for the first (period - 1) values where insufficient data exists.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `data` - Input data slice
|
||||||
|
/// * `period` - Lookback period
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Vec of rolling maximum values
|
||||||
|
pub fn rolling_max(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
|
||||||
|
if period == 0 {
|
||||||
|
return Err(RaptorError::invalid_parameter("period must be at least 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = data.len();
|
||||||
|
let mut result = vec![f64::NAN; n];
|
||||||
|
|
||||||
|
for i in (period - 1)..n {
|
||||||
|
let start = i + 1 - period;
|
||||||
|
let max_val =
|
||||||
|
data[start..=i]
|
||||||
|
.iter()
|
||||||
|
.fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) });
|
||||||
|
result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val };
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rolling_min() {
|
||||||
|
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
|
||||||
|
let result = rolling_min(&data, 3).unwrap();
|
||||||
|
|
||||||
|
assert!(result[0].is_nan());
|
||||||
|
assert!(result[1].is_nan());
|
||||||
|
assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8)
|
||||||
|
assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2)
|
||||||
|
assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7)
|
||||||
|
assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1)
|
||||||
|
assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rolling_max() {
|
||||||
|
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
|
||||||
|
let result = rolling_max(&data, 3).unwrap();
|
||||||
|
|
||||||
|
assert!(result[0].is_nan());
|
||||||
|
assert!(result[1].is_nan());
|
||||||
|
assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8)
|
||||||
|
assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2)
|
||||||
|
assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7)
|
||||||
|
assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1)
|
||||||
|
assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_period() {
|
||||||
|
let data = vec![1.0, 2.0, 3.0];
|
||||||
|
assert!(rolling_min(&data, 0).is_err());
|
||||||
|
assert!(rolling_max(&data, 0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -1,7 +1,7 @@
|
|||||||
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
|
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
|
||||||
#![allow(non_local_definitions)]
|
#![allow(non_local_definitions)]
|
||||||
|
|
||||||
//! RaptorBT - High-performance Rust backtesting engine for Quant5.
|
//! RaptorBT - High-performance Rust backtesting engine.
|
||||||
//!
|
//!
|
||||||
//! This crate provides a complete backtesting solution with:
|
//! This crate provides a complete backtesting solution with:
|
||||||
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
|
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
|
||||||
@@ -27,6 +27,7 @@ pub mod strategies;
|
|||||||
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
||||||
// Register config classes
|
// Register config classes
|
||||||
m.add_class::<python::bindings::PyBacktestConfig>()?;
|
m.add_class::<python::bindings::PyBacktestConfig>()?;
|
||||||
|
m.add_class::<python::bindings::PyInstrumentConfig>()?;
|
||||||
m.add_class::<python::bindings::PyStopConfig>()?;
|
m.add_class::<python::bindings::PyStopConfig>()?;
|
||||||
m.add_class::<python::bindings::PyTargetConfig>()?;
|
m.add_class::<python::bindings::PyTargetConfig>()?;
|
||||||
|
|
||||||
@@ -41,6 +42,27 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
|||||||
m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?;
|
||||||
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_tick_backtest, m)?)?;
|
||||||
|
|
||||||
|
// Register batch spread backtest
|
||||||
|
m.add_class::<python::bindings::PyBatchSpreadItem>()?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::batch_spread_backtest, m)?)?;
|
||||||
|
|
||||||
|
// Register Monte Carlo simulation
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?;
|
||||||
|
|
||||||
|
// Register tick signal functions
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?;
|
||||||
|
|
||||||
|
// Register tick feature functions
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?;
|
||||||
|
|
||||||
// Register indicator functions
|
// Register indicator functions
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
|
||||||
@@ -53,6 +75,8 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
|
|||||||
m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?;
|
||||||
m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?;
|
m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
|
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
|
||||||
|
|
||||||
|
use crate::core::types::BacktestMetrics;
|
||||||
|
|
||||||
/// Streaming metrics calculator using Welford's algorithm.
|
/// Streaming metrics calculator using Welford's algorithm.
|
||||||
///
|
///
|
||||||
/// Allows incremental calculation of statistics without storing all values.
|
/// Allows incremental calculation of statistics without storing all values.
|
||||||
|
/// Also tracks equity and drawdown for backtesting.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StreamingMetrics {
|
pub struct StreamingMetrics {
|
||||||
/// Number of observations.
|
/// Number of observations.
|
||||||
@@ -27,6 +30,59 @@ pub struct StreamingMetrics {
|
|||||||
count_positive: usize,
|
count_positive: usize,
|
||||||
/// Count of negative returns.
|
/// Count of negative returns.
|
||||||
count_negative: usize,
|
count_negative: usize,
|
||||||
|
|
||||||
|
// === Equity and drawdown tracking ===
|
||||||
|
/// Initial capital.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
initial_capital: f64,
|
||||||
|
/// Peak equity value (for drawdown calculation).
|
||||||
|
peak_equity: f64,
|
||||||
|
/// Current equity value.
|
||||||
|
current_equity: f64,
|
||||||
|
/// Maximum drawdown percentage.
|
||||||
|
max_drawdown_pct: f64,
|
||||||
|
/// Current drawdown percentage.
|
||||||
|
current_drawdown: f64,
|
||||||
|
/// Bars since peak (for max drawdown duration).
|
||||||
|
bars_since_peak: usize,
|
||||||
|
/// Maximum drawdown duration in bars.
|
||||||
|
max_drawdown_duration: usize,
|
||||||
|
|
||||||
|
// === Trade tracking ===
|
||||||
|
/// Number of trades.
|
||||||
|
trade_count: usize,
|
||||||
|
/// Number of winning trades.
|
||||||
|
winning_trades: usize,
|
||||||
|
/// Number of losing trades.
|
||||||
|
losing_trades: usize,
|
||||||
|
/// Sum of winning trade P&L.
|
||||||
|
sum_wins: f64,
|
||||||
|
/// Sum of losing trade P&L.
|
||||||
|
sum_losses: f64,
|
||||||
|
/// Sum of trade return percentages.
|
||||||
|
sum_trade_returns: f64,
|
||||||
|
/// Sum of squared trade return percentages (for SQN).
|
||||||
|
sum_trade_returns_sq: f64,
|
||||||
|
/// Best trade return percentage.
|
||||||
|
best_trade_pct: f64,
|
||||||
|
/// Worst trade return percentage.
|
||||||
|
worst_trade_pct: f64,
|
||||||
|
/// Sum of winning trade durations.
|
||||||
|
sum_winning_duration: usize,
|
||||||
|
/// Sum of losing trade durations.
|
||||||
|
sum_losing_duration: usize,
|
||||||
|
/// Current consecutive wins.
|
||||||
|
current_consecutive_wins: usize,
|
||||||
|
/// Current consecutive losses.
|
||||||
|
current_consecutive_losses: usize,
|
||||||
|
/// Maximum consecutive wins.
|
||||||
|
max_consecutive_wins: usize,
|
||||||
|
/// Maximum consecutive losses.
|
||||||
|
max_consecutive_losses: usize,
|
||||||
|
/// Total holding period (bars).
|
||||||
|
total_holding_period: usize,
|
||||||
|
/// Total fees paid.
|
||||||
|
total_fees: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StreamingMetrics {
|
impl Default for StreamingMetrics {
|
||||||
@@ -38,6 +94,11 @@ impl Default for StreamingMetrics {
|
|||||||
impl StreamingMetrics {
|
impl StreamingMetrics {
|
||||||
/// Create a new streaming metrics calculator.
|
/// Create a new streaming metrics calculator.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
|
Self::with_initial_capital(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new streaming metrics calculator with initial capital.
|
||||||
|
pub fn with_initial_capital(initial_capital: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
count: 0,
|
count: 0,
|
||||||
mean: 0.0,
|
mean: 0.0,
|
||||||
@@ -49,6 +110,32 @@ impl StreamingMetrics {
|
|||||||
sum_negative: 0.0,
|
sum_negative: 0.0,
|
||||||
count_positive: 0,
|
count_positive: 0,
|
||||||
count_negative: 0,
|
count_negative: 0,
|
||||||
|
// Equity tracking
|
||||||
|
initial_capital,
|
||||||
|
peak_equity: initial_capital,
|
||||||
|
current_equity: initial_capital,
|
||||||
|
max_drawdown_pct: 0.0,
|
||||||
|
current_drawdown: 0.0,
|
||||||
|
bars_since_peak: 0,
|
||||||
|
max_drawdown_duration: 0,
|
||||||
|
// Trade tracking
|
||||||
|
trade_count: 0,
|
||||||
|
winning_trades: 0,
|
||||||
|
losing_trades: 0,
|
||||||
|
sum_wins: 0.0,
|
||||||
|
sum_losses: 0.0,
|
||||||
|
sum_trade_returns: 0.0,
|
||||||
|
sum_trade_returns_sq: 0.0,
|
||||||
|
best_trade_pct: f64::NEG_INFINITY,
|
||||||
|
worst_trade_pct: f64::INFINITY,
|
||||||
|
sum_winning_duration: 0,
|
||||||
|
sum_losing_duration: 0,
|
||||||
|
current_consecutive_wins: 0,
|
||||||
|
current_consecutive_losses: 0,
|
||||||
|
max_consecutive_wins: 0,
|
||||||
|
max_consecutive_losses: 0,
|
||||||
|
total_holding_period: 0,
|
||||||
|
total_fees: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +305,275 @@ impl StreamingMetrics {
|
|||||||
self.profit_factor()
|
self.profit_factor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Equity tracking methods ===
|
||||||
|
|
||||||
|
/// Update equity and calculate drawdown.
|
||||||
|
pub fn update_equity(&mut self, equity: f64) {
|
||||||
|
self.current_equity = equity;
|
||||||
|
|
||||||
|
if equity > self.peak_equity {
|
||||||
|
self.peak_equity = equity;
|
||||||
|
self.bars_since_peak = 0;
|
||||||
|
} else {
|
||||||
|
self.bars_since_peak += 1;
|
||||||
|
if self.bars_since_peak > self.max_drawdown_duration {
|
||||||
|
self.max_drawdown_duration = self.bars_since_peak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate current drawdown percentage
|
||||||
|
if self.peak_equity > 0.0 {
|
||||||
|
self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0;
|
||||||
|
if self.current_drawdown > self.max_drawdown_pct {
|
||||||
|
self.max_drawdown_pct = self.current_drawdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current drawdown percentage.
|
||||||
|
#[inline]
|
||||||
|
pub fn current_drawdown_pct(&self) -> f64 {
|
||||||
|
self.current_drawdown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get maximum drawdown percentage.
|
||||||
|
#[inline]
|
||||||
|
pub fn max_drawdown_pct(&self) -> f64 {
|
||||||
|
self.max_drawdown_pct
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Trade tracking methods ===
|
||||||
|
|
||||||
|
/// Record a completed trade.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `pnl` - Trade profit/loss
|
||||||
|
/// * `return_pct` - Trade return percentage
|
||||||
|
/// * `duration` - Trade duration in bars
|
||||||
|
pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) {
|
||||||
|
self.trade_count += 1;
|
||||||
|
self.sum_trade_returns += return_pct;
|
||||||
|
self.sum_trade_returns_sq += return_pct * return_pct;
|
||||||
|
self.total_holding_period += duration;
|
||||||
|
|
||||||
|
// Track best/worst trades
|
||||||
|
if return_pct > self.best_trade_pct {
|
||||||
|
self.best_trade_pct = return_pct;
|
||||||
|
}
|
||||||
|
if return_pct < self.worst_trade_pct {
|
||||||
|
self.worst_trade_pct = return_pct;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pnl > 0.0 {
|
||||||
|
self.winning_trades += 1;
|
||||||
|
self.sum_wins += pnl;
|
||||||
|
self.sum_winning_duration += duration;
|
||||||
|
self.current_consecutive_wins += 1;
|
||||||
|
self.current_consecutive_losses = 0;
|
||||||
|
if self.current_consecutive_wins > self.max_consecutive_wins {
|
||||||
|
self.max_consecutive_wins = self.current_consecutive_wins;
|
||||||
|
}
|
||||||
|
} else if pnl < 0.0 {
|
||||||
|
self.losing_trades += 1;
|
||||||
|
self.sum_losses += pnl.abs();
|
||||||
|
self.sum_losing_duration += duration;
|
||||||
|
self.current_consecutive_losses += 1;
|
||||||
|
self.current_consecutive_wins = 0;
|
||||||
|
if self.current_consecutive_losses > self.max_consecutive_losses {
|
||||||
|
self.max_consecutive_losses = self.current_consecutive_losses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record fees paid.
|
||||||
|
pub fn record_fees(&mut self, fees: f64) {
|
||||||
|
self.total_fees += fees;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize metrics and produce BacktestMetrics.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `initial_capital` - Starting capital
|
||||||
|
/// * `final_value` - Ending portfolio value
|
||||||
|
/// * `returns` - Array of period returns for ratio calculations
|
||||||
|
pub fn finalize(
|
||||||
|
&self,
|
||||||
|
initial_capital: f64,
|
||||||
|
final_value: f64,
|
||||||
|
returns: &[f64],
|
||||||
|
) -> BacktestMetrics {
|
||||||
|
// Calculate return metrics from the returns array
|
||||||
|
let mut return_metrics = StreamingMetrics::new();
|
||||||
|
for &r in returns {
|
||||||
|
if !r.is_nan() {
|
||||||
|
return_metrics.update(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_return_pct = if initial_capital > 0.0 {
|
||||||
|
(final_value - initial_capital) / initial_capital * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate trade-based metrics
|
||||||
|
let win_rate_pct = if self.trade_count > 0 {
|
||||||
|
self.winning_trades as f64 / self.trade_count as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let profit_factor = if self.sum_losses > 0.0 {
|
||||||
|
self.sum_wins / self.sum_losses
|
||||||
|
} else if self.sum_wins > 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_trade_return_pct = if self.trade_count > 0 {
|
||||||
|
self.sum_trade_returns / self.trade_count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_win_pct = if self.winning_trades > 0 {
|
||||||
|
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_loss_pct = if self.losing_trades > 0 {
|
||||||
|
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_winning_duration = if self.winning_trades > 0 {
|
||||||
|
self.sum_winning_duration as f64 / self.winning_trades as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_losing_duration = if self.losing_trades > 0 {
|
||||||
|
self.sum_losing_duration as f64 / self.losing_trades as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let avg_holding_period = if self.trade_count > 0 {
|
||||||
|
self.total_holding_period as f64 / self.trade_count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expectancy: average profit per trade
|
||||||
|
let expectancy = if self.trade_count > 0 {
|
||||||
|
(self.sum_wins - self.sum_losses) / self.trade_count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// SQN (System Quality Number)
|
||||||
|
let sqn = if self.trade_count > 1 {
|
||||||
|
let mean_return = self.sum_trade_returns / self.trade_count as f64;
|
||||||
|
let variance =
|
||||||
|
(self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return);
|
||||||
|
let std_dev = variance.max(0.0).sqrt();
|
||||||
|
if std_dev > 0.0 {
|
||||||
|
(mean_return / std_dev) * (self.trade_count as f64).sqrt()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sharpe ratio (annualized, assuming 252 trading days)
|
||||||
|
let sharpe_ratio = return_metrics.sharpe_ratio(252.0);
|
||||||
|
|
||||||
|
// Sortino ratio (annualized)
|
||||||
|
let sortino_ratio = return_metrics.sortino_ratio(252.0);
|
||||||
|
|
||||||
|
// Calmar ratio (annualized return / max drawdown)
|
||||||
|
let calmar_ratio = if self.max_drawdown_pct > 0.0 {
|
||||||
|
total_return_pct / self.max_drawdown_pct
|
||||||
|
} else if total_return_pct > 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Omega ratio
|
||||||
|
let omega_ratio = return_metrics.omega_ratio();
|
||||||
|
|
||||||
|
// Best/worst trade handling (handle edge cases)
|
||||||
|
let best_trade_pct =
|
||||||
|
if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct };
|
||||||
|
let worst_trade_pct =
|
||||||
|
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
|
||||||
|
|
||||||
|
// Payoff ratio: average win / average loss (absolute value)
|
||||||
|
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
|
||||||
|
avg_win_pct / avg_loss_pct.abs()
|
||||||
|
} else if avg_win_pct > 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recovery factor: net profit / max drawdown (absolute value)
|
||||||
|
let net_profit = final_value - initial_capital;
|
||||||
|
let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 {
|
||||||
|
let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital;
|
||||||
|
if max_dd_absolute > 0.0 {
|
||||||
|
net_profit / max_dd_absolute
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
} else if net_profit > 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
BacktestMetrics {
|
||||||
|
total_return_pct,
|
||||||
|
sharpe_ratio,
|
||||||
|
sortino_ratio,
|
||||||
|
calmar_ratio,
|
||||||
|
omega_ratio,
|
||||||
|
max_drawdown_pct: self.max_drawdown_pct,
|
||||||
|
max_drawdown_duration: self.max_drawdown_duration,
|
||||||
|
win_rate_pct,
|
||||||
|
profit_factor,
|
||||||
|
expectancy,
|
||||||
|
sqn,
|
||||||
|
total_trades: self.trade_count,
|
||||||
|
total_closed_trades: self.trade_count,
|
||||||
|
total_open_trades: 0,
|
||||||
|
open_trade_pnl: 0.0,
|
||||||
|
winning_trades: self.winning_trades,
|
||||||
|
losing_trades: self.losing_trades,
|
||||||
|
start_value: initial_capital,
|
||||||
|
end_value: final_value,
|
||||||
|
total_fees_paid: self.total_fees,
|
||||||
|
best_trade_pct,
|
||||||
|
worst_trade_pct,
|
||||||
|
avg_trade_return_pct,
|
||||||
|
avg_win_pct,
|
||||||
|
avg_loss_pct,
|
||||||
|
avg_winning_duration,
|
||||||
|
avg_losing_duration,
|
||||||
|
max_consecutive_wins: self.max_consecutive_wins,
|
||||||
|
max_consecutive_losses: self.max_consecutive_losses,
|
||||||
|
avg_holding_period,
|
||||||
|
exposure_pct: 0.0, // TODO: calculate based on time in market
|
||||||
|
payoff_ratio,
|
||||||
|
recovery_factor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset all metrics.
|
/// Reset all metrics.
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
*self = Self::new();
|
*self = Self::new();
|
||||||
|
|||||||
+123
-25
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::core::types::{
|
use crate::core::types::{
|
||||||
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
|
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::execution::{FeeModel, FillPrice, SlippageModel};
|
||||||
use crate::indicators::volatility::atr;
|
use crate::indicators::volatility::atr;
|
||||||
@@ -69,6 +69,24 @@ impl PortfolioEngine {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Backtest result
|
/// Backtest result
|
||||||
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
|
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();
|
let n = ohlcv.len();
|
||||||
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
|
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 streaming = StreamingMetrics::new();
|
||||||
let mut peak_equity = cash;
|
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
|
// Pre-calculate ATR for ATR-based stops
|
||||||
let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. })
|
let atr_values = if matches!(effective_stop, StopConfig::Atr { .. })
|
||||||
|| matches!(self.config.target, TargetConfig::Atr { .. })
|
|| matches!(effective_target, TargetConfig::Atr { .. })
|
||||||
{
|
{
|
||||||
let period = match self.config.stop {
|
let period = match effective_stop {
|
||||||
StopConfig::Atr { period, .. } => period,
|
StopConfig::Atr { period, .. } => *period,
|
||||||
_ => match self.config.target {
|
_ => match effective_target {
|
||||||
TargetConfig::Atr { period, .. } => period,
|
TargetConfig::Atr { period, .. } => *period,
|
||||||
_ => 14,
|
_ => 14,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -188,8 +212,8 @@ impl PortfolioEngine {
|
|||||||
|
|
||||||
// Update trailing stop if position still open
|
// Update trailing stop if position still open
|
||||||
if position.is_in_position() {
|
if position.is_in_position() {
|
||||||
if let StopConfig::Trailing { percent } = self.config.stop {
|
if let StopConfig::Trailing { percent } = effective_stop {
|
||||||
position.update_trailing_stop(percent);
|
position.update_trailing_stop(*percent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,26 +231,37 @@ impl PortfolioEngine {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate position size
|
// Calculate position size
|
||||||
// VectorBT formula: size = cash / (price * (1 + fees))
|
// Use per-instrument capital if set, capped at available cash
|
||||||
// This ensures the position value plus entry fee equals available cash
|
let available = inst_config
|
||||||
|
.and_then(|ic| ic.alloted_capital)
|
||||||
|
.map(|cap| cap.min(cash))
|
||||||
|
.unwrap_or(cash);
|
||||||
|
|
||||||
|
// Position sizing: size = cash / (price * (1 + fees))
|
||||||
|
// Ensures position value plus entry fee equals available cash
|
||||||
let fee_rate = self.config.fees;
|
let fee_rate = self.config.fees;
|
||||||
let size = if let Some(ref sizes) = signals.position_sizes {
|
let raw_size = if let Some(ref sizes) = signals.position_sizes {
|
||||||
sizes[i] * cash / (adjusted_price * (1.0 + fee_rate))
|
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
|
||||||
} else {
|
} 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 {
|
if size > 0.0 {
|
||||||
// Calculate entry fees
|
// Calculate entry fees
|
||||||
let entry_fees =
|
let entry_fees =
|
||||||
self.fee_model.calculate(adjusted_price, size, signals.direction);
|
self.fee_model.calculate(adjusted_price, size, signals.direction);
|
||||||
|
|
||||||
// Calculate stop and target prices
|
// 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,
|
adjusted_price,
|
||||||
signals.direction,
|
signals.direction,
|
||||||
&atr_values,
|
&atr_values,
|
||||||
i,
|
i,
|
||||||
|
effective_stop,
|
||||||
|
effective_target,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Open position (passing entry_fees for trade PnL tracking)
|
// Open position (passing entry_fees for trade PnL tracking)
|
||||||
@@ -264,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(
|
||||||
@@ -310,18 +344,39 @@ impl PortfolioEngine {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate stop and target prices.
|
/// Calculate stop and target prices using the global config.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn calculate_stop_target(
|
fn calculate_stop_target(
|
||||||
&self,
|
&self,
|
||||||
entry_price: Price,
|
entry_price: Price,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
atr_values: &[f64],
|
atr_values: &[f64],
|
||||||
idx: usize,
|
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>) {
|
) -> (Option<Price>, Option<Price>) {
|
||||||
let multiplier = direction.multiplier();
|
let multiplier = direction.multiplier();
|
||||||
|
|
||||||
// Calculate stop price
|
// Calculate stop price
|
||||||
let stop_price = match self.config.stop {
|
let stop_price = match stop_config {
|
||||||
StopConfig::None => None,
|
StopConfig::None => None,
|
||||||
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
|
||||||
StopConfig::Atr { multiplier: m, .. } => {
|
StopConfig::Atr { multiplier: m, .. } => {
|
||||||
@@ -336,7 +391,7 @@ impl PortfolioEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate target price
|
// Calculate target price
|
||||||
let target_price = match self.config.target {
|
let target_price = match target_config {
|
||||||
TargetConfig::None => None,
|
TargetConfig::None => None,
|
||||||
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
|
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
|
||||||
TargetConfig::Atr { multiplier: m, .. } => {
|
TargetConfig::Atr { multiplier: m, .. } => {
|
||||||
@@ -516,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;
|
||||||
@@ -535,6 +588,30 @@ impl PortfolioEngine {
|
|||||||
0.0
|
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 {
|
BacktestMetrics {
|
||||||
total_return_pct,
|
total_return_pct,
|
||||||
sharpe_ratio,
|
sharpe_ratio,
|
||||||
@@ -567,6 +644,8 @@ impl PortfolioEngine {
|
|||||||
max_consecutive_losses,
|
max_consecutive_losses,
|
||||||
avg_holding_period,
|
avg_holding_period,
|
||||||
exposure_pct,
|
exposure_pct,
|
||||||
|
payoff_ratio,
|
||||||
|
recovery_factor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,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;
|
||||||
|
|
||||||
@@ -679,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::*;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
pub mod allocation;
|
pub mod allocation;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod monte_carlo;
|
||||||
pub mod position;
|
pub mod position;
|
||||||
|
|
||||||
pub use allocation::{AllocationStrategy, CapitalAllocator};
|
pub use allocation::{AllocationStrategy, CapitalAllocator};
|
||||||
pub use engine::PortfolioEngine;
|
pub use engine::PortfolioEngine;
|
||||||
|
pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult};
|
||||||
pub use position::PositionManager;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
+697
-6
@@ -3,8 +3,11 @@
|
|||||||
use numpy::{PyArray1, PyReadonlyArray1};
|
use numpy::{PyArray1, PyReadonlyArray1};
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::core::types::{
|
use crate::core::types::{
|
||||||
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
|
BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig,
|
||||||
|
TargetConfig,
|
||||||
};
|
};
|
||||||
use crate::indicators;
|
use crate::indicators;
|
||||||
use crate::signals::synchronizer::SyncMode;
|
use crate::signals::synchronizer::SyncMode;
|
||||||
@@ -15,6 +18,10 @@ use crate::strategies::options::{
|
|||||||
};
|
};
|
||||||
use crate::strategies::pairs::{PairsBacktest, PairsConfig};
|
use crate::strategies::pairs::{PairsBacktest, PairsConfig};
|
||||||
use crate::strategies::single::SingleBacktest;
|
use crate::strategies::single::SingleBacktest;
|
||||||
|
use crate::strategies::spreads::{
|
||||||
|
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
||||||
|
};
|
||||||
|
use crate::strategies::tick::{TickBacktest, TickBacktestConfig};
|
||||||
|
|
||||||
use super::numpy_bridge::*;
|
use super::numpy_bridge::*;
|
||||||
|
|
||||||
@@ -97,6 +104,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.
|
/// Python-exposed stop configuration.
|
||||||
#[pyclass]
|
#[pyclass]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -326,6 +420,10 @@ pub struct PyBacktestMetrics {
|
|||||||
pub avg_holding_period: f64,
|
pub avg_holding_period: f64,
|
||||||
#[pyo3(get)]
|
#[pyo3(get)]
|
||||||
pub exposure_pct: f64,
|
pub exposure_pct: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub payoff_ratio: f64,
|
||||||
|
#[pyo3(get)]
|
||||||
|
pub recovery_factor: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pymethods]
|
#[pymethods]
|
||||||
@@ -337,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)?;
|
||||||
@@ -416,7 +514,7 @@ impl PyBacktestResult {
|
|||||||
|
|
||||||
/// Run single instrument backtest.
|
/// Run single instrument backtest.
|
||||||
#[pyfunction]
|
#[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>(
|
pub fn run_single_backtest<'py>(
|
||||||
_py: Python<'py>,
|
_py: Python<'py>,
|
||||||
timestamps: PyReadonlyArray1<i64>,
|
timestamps: PyReadonlyArray1<i64>,
|
||||||
@@ -432,6 +530,7 @@ pub fn run_single_backtest<'py>(
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
config: Option<&PyBacktestConfig>,
|
config: Option<&PyBacktestConfig>,
|
||||||
position_sizes: Option<PyReadonlyArray1<f64>>,
|
position_sizes: Option<PyReadonlyArray1<f64>>,
|
||||||
|
instrument_config: Option<&PyInstrumentConfig>,
|
||||||
) -> PyResult<PyBacktestResult> {
|
) -> PyResult<PyBacktestResult> {
|
||||||
let ohlcv = OhlcvData {
|
let ohlcv = OhlcvData {
|
||||||
timestamps: numpy_to_vec_i64(timestamps),
|
timestamps: numpy_to_vec_i64(timestamps),
|
||||||
@@ -454,16 +553,17 @@ pub fn run_single_backtest<'py>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
|
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 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))
|
Ok(convert_result(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run basket/collective backtest.
|
/// Run basket/collective backtest.
|
||||||
#[pyfunction]
|
#[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>(
|
pub fn run_basket_backtest<'py>(
|
||||||
_py: Python<'py>,
|
_py: Python<'py>,
|
||||||
instruments: Vec<(
|
instruments: Vec<(
|
||||||
@@ -481,6 +581,7 @@ pub fn run_basket_backtest<'py>(
|
|||||||
)>,
|
)>,
|
||||||
config: Option<&PyBacktestConfig>,
|
config: Option<&PyBacktestConfig>,
|
||||||
sync_mode: &str,
|
sync_mode: &str,
|
||||||
|
instrument_configs: Option<HashMap<String, PyInstrumentConfig>>,
|
||||||
) -> PyResult<PyBacktestResult> {
|
) -> PyResult<PyBacktestResult> {
|
||||||
let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments
|
let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -518,8 +619,15 @@ pub fn run_basket_backtest<'py>(
|
|||||||
..Default::default()
|
..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 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))
|
Ok(convert_result(result))
|
||||||
}
|
}
|
||||||
@@ -673,6 +781,219 @@ pub fn run_pairs_backtest<'py>(
|
|||||||
Ok(convert_result(result))
|
Ok(convert_result(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run spread backtest (multi-leg options).
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None, leg_expiry_timestamps=None))]
|
||||||
|
pub fn run_spread_backtest<'py>(
|
||||||
|
_py: Python<'py>,
|
||||||
|
timestamps: PyReadonlyArray1<i64>,
|
||||||
|
underlying_close: PyReadonlyArray1<f64>,
|
||||||
|
legs_premiums: Vec<PyReadonlyArray1<f64>>,
|
||||||
|
leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size)
|
||||||
|
entries: PyReadonlyArray1<bool>,
|
||||||
|
exits: PyReadonlyArray1<bool>,
|
||||||
|
config: Option<&PyBacktestConfig>,
|
||||||
|
spread_type: &str,
|
||||||
|
max_loss: Option<f64>,
|
||||||
|
target_profit: Option<f64>,
|
||||||
|
leg_expiry_timestamps: Option<Vec<i64>>,
|
||||||
|
) -> PyResult<PyBacktestResult> {
|
||||||
|
let ts = numpy_to_vec_i64(timestamps);
|
||||||
|
let underlying = numpy_to_vec_f64(underlying_close);
|
||||||
|
let premiums: Vec<Vec<f64>> = legs_premiums.into_iter().map(numpy_to_vec_f64).collect();
|
||||||
|
let entry_signals = numpy_to_vec_bool(entries);
|
||||||
|
let exit_signals = numpy_to_vec_bool(exits);
|
||||||
|
|
||||||
|
// Convert leg configs
|
||||||
|
let rust_leg_configs: Vec<LegConfig> = leg_configs
|
||||||
|
.into_iter()
|
||||||
|
.map(|(opt_type, strike, quantity, lot_size)| {
|
||||||
|
let option_type =
|
||||||
|
SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call);
|
||||||
|
LegConfig::new(option_type, strike, quantity, lot_size)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Parse spread type
|
||||||
|
let spread_type_enum = match spread_type.to_lowercase().as_str() {
|
||||||
|
"straddle" => SpreadType::Straddle,
|
||||||
|
"strangle" => SpreadType::Strangle,
|
||||||
|
"vertical_call" | "verticalcall" => SpreadType::VerticalCall,
|
||||||
|
"vertical_put" | "verticalput" => SpreadType::VerticalPut,
|
||||||
|
"iron_condor" | "ironcondor" => SpreadType::IronCondor,
|
||||||
|
"iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly,
|
||||||
|
"butterfly_call" | "butterflycall" => SpreadType::ButterflyCall,
|
||||||
|
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||||
|
"calendar" => SpreadType::Calendar,
|
||||||
|
"diagonal" => SpreadType::Diagonal,
|
||||||
|
"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: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(),
|
||||||
|
spread_type: spread_type_enum,
|
||||||
|
leg_configs: rust_leg_configs,
|
||||||
|
max_loss,
|
||||||
|
target_profit,
|
||||||
|
close_at_eod: false,
|
||||||
|
leg_expiry_timestamps,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backtest = SpreadBacktest::new(spread_config);
|
||||||
|
let result = backtest.run(&ts, &underlying, &premiums, &entry_signals, &exit_signals);
|
||||||
|
|
||||||
|
Ok(convert_result(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single spread backtest item for batch execution.
|
||||||
|
#[pyclass]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PyBatchSpreadItem {
|
||||||
|
#[pyo3(get, set)]
|
||||||
|
pub strategy_id: String,
|
||||||
|
pub legs_premiums: Vec<Vec<f64>>,
|
||||||
|
pub leg_configs: Vec<(String, f64, i32, usize)>,
|
||||||
|
pub entries: Vec<bool>,
|
||||||
|
pub exits: Vec<bool>,
|
||||||
|
#[pyo3(get, set)]
|
||||||
|
pub spread_type: String,
|
||||||
|
#[pyo3(get, set)]
|
||||||
|
pub max_loss: Option<f64>,
|
||||||
|
#[pyo3(get, set)]
|
||||||
|
pub target_profit: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl PyBatchSpreadItem {
|
||||||
|
#[new]
|
||||||
|
#[pyo3(signature = (strategy_id, legs_premiums, leg_configs, entries, exits,
|
||||||
|
spread_type="custom", max_loss=None, target_profit=None))]
|
||||||
|
fn new(
|
||||||
|
strategy_id: String,
|
||||||
|
legs_premiums: Vec<PyReadonlyArray1<f64>>,
|
||||||
|
leg_configs: Vec<(String, f64, i32, usize)>,
|
||||||
|
entries: PyReadonlyArray1<bool>,
|
||||||
|
exits: PyReadonlyArray1<bool>,
|
||||||
|
spread_type: &str,
|
||||||
|
max_loss: Option<f64>,
|
||||||
|
target_profit: Option<f64>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
strategy_id,
|
||||||
|
legs_premiums: legs_premiums.into_iter().map(numpy_to_vec_f64).collect(),
|
||||||
|
leg_configs,
|
||||||
|
entries: numpy_to_vec_bool(entries),
|
||||||
|
exits: numpy_to_vec_bool(exits),
|
||||||
|
spread_type: spread_type.to_string(),
|
||||||
|
max_loss,
|
||||||
|
target_profit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run multiple spread backtests in parallel via Rayon.
|
||||||
|
///
|
||||||
|
/// Shared data (timestamps, underlying_close) is converted once, then each
|
||||||
|
/// item is backtested on its own Rayon thread with the GIL released.
|
||||||
|
///
|
||||||
|
/// Returns a Vec of (strategy_id, PyBacktestResult) tuples.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (timestamps, underlying_close, items, config=None))]
|
||||||
|
pub fn batch_spread_backtest(
|
||||||
|
py: Python<'_>,
|
||||||
|
timestamps: PyReadonlyArray1<i64>,
|
||||||
|
underlying_close: PyReadonlyArray1<f64>,
|
||||||
|
items: Vec<PyBatchSpreadItem>,
|
||||||
|
config: Option<&PyBacktestConfig>,
|
||||||
|
) -> PyResult<Vec<(String, PyBacktestResult)>> {
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
// Convert shared data while holding GIL
|
||||||
|
let ts = numpy_to_vec_i64(timestamps);
|
||||||
|
let underlying = numpy_to_vec_f64(underlying_close);
|
||||||
|
let base_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default();
|
||||||
|
|
||||||
|
// Prepare each item into a self-contained struct for parallel execution
|
||||||
|
struct PreparedItem {
|
||||||
|
strategy_id: String,
|
||||||
|
premiums: Vec<Vec<f64>>,
|
||||||
|
entries: Vec<bool>,
|
||||||
|
exits: Vec<bool>,
|
||||||
|
spread_config: SpreadConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepared: Vec<PreparedItem> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
let rust_leg_configs: Vec<LegConfig> = item
|
||||||
|
.leg_configs
|
||||||
|
.into_iter()
|
||||||
|
.map(|(opt_type, strike, quantity, lot_size)| {
|
||||||
|
let option_type =
|
||||||
|
SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call);
|
||||||
|
LegConfig::new(option_type, strike, quantity, lot_size)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let spread_type_enum = match item.spread_type.to_lowercase().as_str() {
|
||||||
|
"straddle" => SpreadType::Straddle,
|
||||||
|
"strangle" => SpreadType::Strangle,
|
||||||
|
"vertical_call" | "verticalcall" => SpreadType::VerticalCall,
|
||||||
|
"vertical_put" | "verticalput" => SpreadType::VerticalPut,
|
||||||
|
"iron_condor" | "ironcondor" => SpreadType::IronCondor,
|
||||||
|
"iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly,
|
||||||
|
"butterfly_call" | "butterflycall" => SpreadType::ButterflyCall,
|
||||||
|
"butterfly_put" | "butterflyput" => SpreadType::ButterflyPut,
|
||||||
|
"calendar" => SpreadType::Calendar,
|
||||||
|
"diagonal" => SpreadType::Diagonal,
|
||||||
|
"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.
|
/// Run multi-strategy backtest.
|
||||||
#[pyfunction]
|
#[pyfunction]
|
||||||
#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))]
|
#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))]
|
||||||
@@ -729,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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -903,10 +1495,107 @@ pub fn supertrend<'py>(
|
|||||||
Ok((vec_to_numpy_f64(py, result.supertrend), direction_array))
|
Ok((vec_to_numpy_f64(py, result.supertrend), direction_array))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rolling minimum (Lowest Low Value).
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn rolling_min<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
data: PyReadonlyArray1<f64>,
|
||||||
|
period: usize,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
let vec = numpy_to_vec_f64(data);
|
||||||
|
let result = indicators::rolling::rolling_min(&vec, period)
|
||||||
|
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||||
|
Ok(vec_to_numpy_f64(py, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling maximum (Highest High Value).
|
||||||
|
#[pyfunction]
|
||||||
|
pub fn rolling_max<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
data: PyReadonlyArray1<f64>,
|
||||||
|
period: usize,
|
||||||
|
) -> PyResult<&'py PyArray1<f64>> {
|
||||||
|
let vec = numpy_to_vec_f64(data);
|
||||||
|
let result = indicators::rolling::rolling_max(&vec, period)
|
||||||
|
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||||
|
Ok(vec_to_numpy_f64(py, result))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// 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.
|
/// Convert Rust BacktestResult to Python PyBacktestResult.
|
||||||
fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult {
|
fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult {
|
||||||
let metrics = PyBacktestMetrics {
|
let metrics = PyBacktestMetrics {
|
||||||
@@ -941,6 +1630,8 @@ fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResul
|
|||||||
max_consecutive_losses: result.metrics.max_consecutive_losses,
|
max_consecutive_losses: result.metrics.max_consecutive_losses,
|
||||||
avg_holding_period: result.metrics.avg_holding_period,
|
avg_holding_period: result.metrics.avg_holding_period,
|
||||||
exposure_pct: result.metrics.exposure_pct,
|
exposure_pct: result.metrics.exposure_pct,
|
||||||
|
payoff_ratio: result.metrics.payoff_ratio,
|
||||||
|
recovery_factor: result.metrics.recovery_factor,
|
||||||
};
|
};
|
||||||
|
|
||||||
let trades: Vec<PyTrade> = result
|
let trades: Vec<PyTrade> = result
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
//!
|
//!
|
||||||
//! Supports multiple instruments with synchronized signals.
|
//! Supports multiple instruments with synchronized signals.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::core::types::{
|
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::execution::FeeModel;
|
||||||
use crate::metrics::streaming::StreamingMetrics;
|
use crate::metrics::streaming::StreamingMetrics;
|
||||||
@@ -74,6 +77,22 @@ impl BasketBacktest {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Combined backtest result
|
/// Combined backtest result
|
||||||
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
|
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() {
|
if instruments.is_empty() {
|
||||||
return self.empty_result();
|
return self.empty_result();
|
||||||
}
|
}
|
||||||
@@ -168,7 +187,15 @@ impl BasketBacktest {
|
|||||||
// Calculate position sizes
|
// Calculate position sizes
|
||||||
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
|
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
|
||||||
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).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
|
// Enter positions
|
||||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||||
@@ -249,7 +276,21 @@ impl BasketBacktest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate position sizes for each instrument.
|
/// Calculate position sizes for each instrument.
|
||||||
|
#[allow(dead_code)]
|
||||||
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
|
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 n = prices.len();
|
||||||
let total_weight: f64 = weights.iter().sum();
|
let total_weight: f64 = weights.iter().sum();
|
||||||
|
|
||||||
@@ -260,12 +301,26 @@ impl BasketBacktest {
|
|||||||
prices
|
prices
|
||||||
.iter()
|
.iter()
|
||||||
.zip(weights.iter())
|
.zip(weights.iter())
|
||||||
.map(|(&price, &weight)| {
|
.enumerate()
|
||||||
|
.map(|(idx, (&price, &weight))| {
|
||||||
if price <= 0.0 {
|
if price <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let allocation = available_capital * (weight / total_weight);
|
let default_allocation = available_capital * (weight / total_weight);
|
||||||
allocation / price
|
|
||||||
|
// 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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ pub mod multi;
|
|||||||
pub mod options;
|
pub mod options;
|
||||||
pub mod pairs;
|
pub mod pairs;
|
||||||
pub mod single;
|
pub mod single;
|
||||||
|
pub mod spreads;
|
||||||
|
pub mod tick;
|
||||||
|
|
||||||
pub use basket::BasketBacktest;
|
pub use basket::BasketBacktest;
|
||||||
pub use multi::MultiStrategyBacktest;
|
pub use multi::MultiStrategyBacktest;
|
||||||
pub use options::OptionsBacktest;
|
pub use options::OptionsBacktest;
|
||||||
pub use pairs::PairsBacktest;
|
pub use pairs::PairsBacktest;
|
||||||
pub use single::SingleBacktest;
|
pub use single::SingleBacktest;
|
||||||
|
pub use spreads::{
|
||||||
|
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
|
||||||
|
};
|
||||||
|
pub use tick::{TickBacktest, TickBacktestConfig};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! Single instrument backtest implementation.
|
//! 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;
|
use crate::portfolio::engine::PortfolioEngine;
|
||||||
|
|
||||||
/// Single instrument backtest runner.
|
/// Single instrument backtest runner.
|
||||||
@@ -28,6 +30,24 @@ impl SingleBacktest {
|
|||||||
self.engine.run_single(ohlcv, signals)
|
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.
|
/// Run backtest from raw arrays.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
//! Multi-leg options spread backtesting implementation.
|
||||||
|
//!
|
||||||
|
//! Provides high-performance spread backtesting for:
|
||||||
|
//! - Straddles and Strangles
|
||||||
|
//! - Vertical spreads (bull/bear call/put)
|
||||||
|
//! - Iron Condors and Iron Butterflies
|
||||||
|
//! - Calendar and Diagonal spreads
|
||||||
|
//!
|
||||||
|
//! Key features:
|
||||||
|
//! - Single-pass O(n) algorithm
|
||||||
|
//! - Coordinated entry/exit across all legs
|
||||||
|
//! - Net premium P&L calculation
|
||||||
|
//! - Combined Greeks tracking
|
||||||
|
|
||||||
|
use crate::core::types::{
|
||||||
|
BacktestConfig, BacktestMetrics, BacktestResult, Direction, ExitReason, Trade,
|
||||||
|
};
|
||||||
|
use crate::metrics::streaming::StreamingMetrics;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Spread type enumeration.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum SpreadType {
|
||||||
|
Straddle,
|
||||||
|
Strangle,
|
||||||
|
VerticalCall,
|
||||||
|
VerticalPut,
|
||||||
|
IronCondor,
|
||||||
|
IronButterfly,
|
||||||
|
ButterflyCall,
|
||||||
|
ButterflyPut,
|
||||||
|
Calendar,
|
||||||
|
Diagonal,
|
||||||
|
LongCall,
|
||||||
|
LongPut,
|
||||||
|
NakedCall,
|
||||||
|
NakedPut,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Option type for a leg.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum OptionType {
|
||||||
|
Call,
|
||||||
|
Put,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionType {
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_uppercase().as_str() {
|
||||||
|
"CE" | "CALL" | "C" => Some(OptionType::Call),
|
||||||
|
"PE" | "PUT" | "P" => Some(OptionType::Put),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a single leg of a spread.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LegConfig {
|
||||||
|
/// Option type (Call or Put).
|
||||||
|
pub option_type: OptionType,
|
||||||
|
/// Strike price.
|
||||||
|
pub strike: f64,
|
||||||
|
/// Position quantity (+1 long, -1 short).
|
||||||
|
pub quantity: i32,
|
||||||
|
/// Lot size for the option.
|
||||||
|
pub lot_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegConfig {
|
||||||
|
pub fn new(option_type: OptionType, strike: f64, quantity: i32, lot_size: usize) -> Self {
|
||||||
|
Self { option_type, strike, quantity, lot_size }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a long position.
|
||||||
|
pub fn is_long(&self) -> bool {
|
||||||
|
self.quantity > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a short position.
|
||||||
|
pub fn is_short(&self) -> bool {
|
||||||
|
self.quantity < 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for spread backtest.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpreadConfig {
|
||||||
|
/// Base backtest configuration.
|
||||||
|
pub base: BacktestConfig,
|
||||||
|
/// Spread type.
|
||||||
|
pub spread_type: SpreadType,
|
||||||
|
/// Leg configurations.
|
||||||
|
pub leg_configs: Vec<LegConfig>,
|
||||||
|
/// Maximum loss threshold (optional, for early exit).
|
||||||
|
pub max_loss: Option<f64>,
|
||||||
|
/// Target profit threshold (optional, for early exit).
|
||||||
|
pub target_profit: Option<f64>,
|
||||||
|
/// Whether to close at end of day.
|
||||||
|
pub close_at_eod: bool,
|
||||||
|
/// 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 {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base: BacktestConfig::default(),
|
||||||
|
spread_type: SpreadType::Custom,
|
||||||
|
leg_configs: Vec::new(),
|
||||||
|
max_loss: None,
|
||||||
|
target_profit: None,
|
||||||
|
close_at_eod: false,
|
||||||
|
leg_expiry_timestamps: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for a single leg position.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LegPosition {
|
||||||
|
/// Entry premium price.
|
||||||
|
pub entry_premium: f64,
|
||||||
|
/// Entry index.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub entry_idx: usize,
|
||||||
|
/// Current premium price.
|
||||||
|
pub current_premium: f64,
|
||||||
|
/// Leg configuration.
|
||||||
|
pub config: LegConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegPosition {
|
||||||
|
fn new(config: LegConfig, entry_premium: f64, entry_idx: usize) -> Self {
|
||||||
|
Self { entry_premium, entry_idx, current_premium: entry_premium, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate unrealized P&L for this leg.
|
||||||
|
fn unrealized_pnl(&self) -> f64 {
|
||||||
|
// For short positions: profit when premium decreases
|
||||||
|
// For long positions: profit when premium increases
|
||||||
|
let premium_change = self.current_premium - self.entry_premium;
|
||||||
|
let quantity = self.config.quantity as f64;
|
||||||
|
let lot_size = self.config.lot_size as f64;
|
||||||
|
-quantity * premium_change * lot_size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spread position state.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct SpreadPosition {
|
||||||
|
/// Individual leg positions.
|
||||||
|
pub legs: Vec<LegPosition>,
|
||||||
|
/// Entry bar index.
|
||||||
|
pub entry_idx: usize,
|
||||||
|
/// Entry net premium (positive = credit, negative = debit).
|
||||||
|
pub entry_net_premium: f64,
|
||||||
|
/// Entry timestamp.
|
||||||
|
pub entry_time: i64,
|
||||||
|
/// Whether position is open.
|
||||||
|
pub is_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpreadPosition {
|
||||||
|
fn new(legs: Vec<LegPosition>, entry_idx: usize, entry_time: i64) -> Self {
|
||||||
|
let entry_net_premium: f64 = legs
|
||||||
|
.iter()
|
||||||
|
.map(|leg| leg.entry_premium * leg.config.quantity as f64 * leg.config.lot_size as f64)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
Self { legs, entry_idx, entry_net_premium, entry_time, is_open: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate total unrealized P&L across all legs.
|
||||||
|
fn total_unrealized_pnl(&self) -> f64 {
|
||||||
|
self.legs.iter().map(|leg| leg.unrealized_pnl()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update leg premiums.
|
||||||
|
fn update_premiums(&mut self, leg_premiums: &[f64]) {
|
||||||
|
for (leg, &premium) in self.legs.iter_mut().zip(leg_premiums.iter()) {
|
||||||
|
leg.current_premium = premium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the position and return P&L.
|
||||||
|
fn close(&mut self) -> f64 {
|
||||||
|
self.is_open = false;
|
||||||
|
self.total_unrealized_pnl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spread backtest runner.
|
||||||
|
pub struct SpreadBacktest {
|
||||||
|
config: SpreadConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpreadBacktest {
|
||||||
|
/// Create a new spread backtest.
|
||||||
|
pub fn new(config: SpreadConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the spread backtest.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `timestamps` - Timestamp array
|
||||||
|
/// * `underlying_close` - Underlying close prices
|
||||||
|
/// * `legs_premiums` - Premium series for each leg (Vec of Vec)
|
||||||
|
/// * `entries` - Entry signals
|
||||||
|
/// * `exits` - Exit signals
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Backtest result with metrics, trades, and equity curve
|
||||||
|
pub fn run(
|
||||||
|
&self,
|
||||||
|
timestamps: &[i64],
|
||||||
|
_underlying_close: &[f64],
|
||||||
|
legs_premiums: &[Vec<f64>],
|
||||||
|
entries: &[bool],
|
||||||
|
exits: &[bool],
|
||||||
|
) -> BacktestResult {
|
||||||
|
let n = timestamps.len();
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if legs_premiums.len() != self.config.leg_configs.len() {
|
||||||
|
return self.empty_result(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
for premiums in legs_premiums {
|
||||||
|
if premiums.len() != n {
|
||||||
|
return self.empty_result(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut metrics = StreamingMetrics::with_initial_capital(self.config.base.initial_capital);
|
||||||
|
let mut equity_curve = Vec::with_capacity(n);
|
||||||
|
let mut drawdown_curve = Vec::with_capacity(n);
|
||||||
|
let mut returns = Vec::with_capacity(n);
|
||||||
|
let mut trades: Vec<Trade> = Vec::new();
|
||||||
|
let mut trade_id: u64 = 0;
|
||||||
|
|
||||||
|
let mut cash = self.config.base.initial_capital;
|
||||||
|
let mut position: Option<SpreadPosition> = None;
|
||||||
|
let mut prev_equity = cash;
|
||||||
|
|
||||||
|
// Single-pass O(n) algorithm
|
||||||
|
for i in 0..n {
|
||||||
|
// Get current leg premiums
|
||||||
|
let current_premiums: Vec<f64> = legs_premiums.iter().map(|p| p[i]).collect();
|
||||||
|
|
||||||
|
// Update position premiums if open
|
||||||
|
if let Some(ref mut pos) = position {
|
||||||
|
pos.update_premiums(¤t_premiums);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate unrealized P&L for exit checks
|
||||||
|
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
|
||||||
|
|
||||||
|
// Check 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));
|
||||||
|
|
||||||
|
if should_exit {
|
||||||
|
if let Some(mut pos) = position.take() {
|
||||||
|
let pnl = pos.close();
|
||||||
|
let fees = self.calculate_fees(&pos);
|
||||||
|
let net_pnl = pnl - fees;
|
||||||
|
|
||||||
|
cash += net_pnl;
|
||||||
|
|
||||||
|
// Record trade
|
||||||
|
trade_id += 1;
|
||||||
|
let exit_reason = if is_expiry {
|
||||||
|
ExitReason::Settlement
|
||||||
|
} else if exits[i] {
|
||||||
|
ExitReason::Signal
|
||||||
|
} else if self.check_max_loss(&Some(pos.clone()), pnl) {
|
||||||
|
ExitReason::StopLoss
|
||||||
|
} else {
|
||||||
|
ExitReason::TakeProfit
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_premium = pos.entry_net_premium;
|
||||||
|
let exit_premium: f64 = current_premiums
|
||||||
|
.iter()
|
||||||
|
.zip(self.config.leg_configs.iter())
|
||||||
|
.map(|(&p, cfg)| p * cfg.quantity as f64 * cfg.lot_size as f64)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
trades.push(Trade {
|
||||||
|
id: trade_id,
|
||||||
|
symbol: "SPREAD".to_string(),
|
||||||
|
entry_idx: pos.entry_idx,
|
||||||
|
exit_idx: i,
|
||||||
|
entry_price: entry_premium,
|
||||||
|
exit_price: exit_premium,
|
||||||
|
size: 1.0,
|
||||||
|
direction: Direction::Long, // Spreads are treated as "long spread"
|
||||||
|
pnl: net_pnl,
|
||||||
|
return_pct: if entry_premium.abs() > 0.0 {
|
||||||
|
net_pnl / entry_premium.abs() * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
entry_time: pos.entry_time,
|
||||||
|
exit_time: timestamps[i],
|
||||||
|
fees,
|
||||||
|
exit_reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.record_trade(
|
||||||
|
net_pnl,
|
||||||
|
net_pnl / entry_premium.abs() * 100.0,
|
||||||
|
i - pos.entry_idx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for entry signals (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
|
||||||
|
.iter()
|
||||||
|
.zip(current_premiums.iter())
|
||||||
|
.map(|(cfg, &premium)| LegPosition::new(cfg.clone(), premium, i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let new_position = SpreadPosition::new(legs, i, timestamps[i]);
|
||||||
|
|
||||||
|
// Calculate entry fees
|
||||||
|
let entry_fees = self.calculate_entry_fees(&new_position);
|
||||||
|
cash -= entry_fees;
|
||||||
|
|
||||||
|
position = Some(new_position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update equity tracking
|
||||||
|
let equity = cash + position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
|
||||||
|
equity_curve.push(equity);
|
||||||
|
|
||||||
|
let daily_return =
|
||||||
|
if prev_equity > 0.0 { (equity - prev_equity) / prev_equity } else { 0.0 };
|
||||||
|
returns.push(daily_return);
|
||||||
|
prev_equity = equity;
|
||||||
|
|
||||||
|
// Update drawdown
|
||||||
|
metrics.update_equity(equity);
|
||||||
|
drawdown_curve.push(metrics.current_drawdown_pct());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close any remaining open position at end
|
||||||
|
if let Some(mut pos) = position.take() {
|
||||||
|
let pnl = pos.close();
|
||||||
|
let fees = self.calculate_fees(&pos);
|
||||||
|
cash += pnl - fees;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize metrics
|
||||||
|
let final_metrics = metrics.finalize(self.config.base.initial_capital, cash, &returns);
|
||||||
|
|
||||||
|
BacktestResult { metrics: final_metrics, equity_curve, drawdown_curve, trades, returns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if max loss threshold is hit.
|
||||||
|
fn check_max_loss(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
|
||||||
|
if let Some(max_loss) = self.config.max_loss {
|
||||||
|
if unrealized_pnl < -max_loss {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if target profit threshold is hit.
|
||||||
|
fn check_target_profit(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
|
||||||
|
if let Some(target) = self.config.target_profit {
|
||||||
|
if unrealized_pnl > target {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate entry fees for a position.
|
||||||
|
fn calculate_entry_fees(&self, position: &SpreadPosition) -> f64 {
|
||||||
|
let total_premium: f64 = position
|
||||||
|
.legs
|
||||||
|
.iter()
|
||||||
|
.map(|leg| leg.entry_premium.abs() * leg.config.lot_size as f64)
|
||||||
|
.sum();
|
||||||
|
total_premium * self.config.base.fees
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate exit fees for a position.
|
||||||
|
fn calculate_fees(&self, position: &SpreadPosition) -> f64 {
|
||||||
|
let total_premium: f64 = position
|
||||||
|
.legs
|
||||||
|
.iter()
|
||||||
|
.map(|leg| leg.current_premium.abs() * leg.config.lot_size as f64)
|
||||||
|
.sum();
|
||||||
|
total_premium * self.config.base.fees * 2.0 // Entry + Exit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty result (used for validation failures).
|
||||||
|
fn empty_result(&self, n: usize) -> BacktestResult {
|
||||||
|
BacktestResult {
|
||||||
|
metrics: BacktestMetrics::default(),
|
||||||
|
equity_curve: vec![self.config.base.initial_capital; n],
|
||||||
|
drawdown_curve: vec![0.0; n],
|
||||||
|
trades: Vec::new(),
|
||||||
|
returns: vec![0.0; n],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to create a straddle spread config.
|
||||||
|
pub fn create_straddle_config(
|
||||||
|
base: BacktestConfig,
|
||||||
|
strike: f64,
|
||||||
|
lot_size: usize,
|
||||||
|
short: bool,
|
||||||
|
) -> SpreadConfig {
|
||||||
|
let quantity = if short { -1 } else { 1 };
|
||||||
|
SpreadConfig {
|
||||||
|
base,
|
||||||
|
spread_type: SpreadType::Straddle,
|
||||||
|
leg_configs: vec![
|
||||||
|
LegConfig::new(OptionType::Call, strike, quantity, lot_size),
|
||||||
|
LegConfig::new(OptionType::Put, strike, quantity, lot_size),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to create a strangle spread config.
|
||||||
|
pub fn create_strangle_config(
|
||||||
|
base: BacktestConfig,
|
||||||
|
call_strike: f64,
|
||||||
|
put_strike: f64,
|
||||||
|
lot_size: usize,
|
||||||
|
short: bool,
|
||||||
|
) -> SpreadConfig {
|
||||||
|
let quantity = if short { -1 } else { 1 };
|
||||||
|
SpreadConfig {
|
||||||
|
base,
|
||||||
|
spread_type: SpreadType::Strangle,
|
||||||
|
leg_configs: vec![
|
||||||
|
LegConfig::new(OptionType::Call, call_strike, quantity, lot_size),
|
||||||
|
LegConfig::new(OptionType::Put, put_strike, quantity, lot_size),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to create an iron condor spread config.
|
||||||
|
pub fn create_iron_condor_config(
|
||||||
|
base: BacktestConfig,
|
||||||
|
short_put_strike: f64,
|
||||||
|
long_put_strike: f64,
|
||||||
|
short_call_strike: f64,
|
||||||
|
long_call_strike: f64,
|
||||||
|
lot_size: usize,
|
||||||
|
) -> SpreadConfig {
|
||||||
|
SpreadConfig {
|
||||||
|
base,
|
||||||
|
spread_type: SpreadType::IronCondor,
|
||||||
|
leg_configs: vec![
|
||||||
|
LegConfig::new(OptionType::Put, short_put_strike, -1, lot_size),
|
||||||
|
LegConfig::new(OptionType::Put, long_put_strike, 1, lot_size),
|
||||||
|
LegConfig::new(OptionType::Call, short_call_strike, -1, lot_size),
|
||||||
|
LegConfig::new(OptionType::Call, long_call_strike, 1, lot_size),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function to create a vertical spread config.
|
||||||
|
pub fn create_vertical_spread_config(
|
||||||
|
base: BacktestConfig,
|
||||||
|
option_type: OptionType,
|
||||||
|
long_strike: f64,
|
||||||
|
short_strike: f64,
|
||||||
|
lot_size: usize,
|
||||||
|
) -> SpreadConfig {
|
||||||
|
let spread_type = match option_type {
|
||||||
|
OptionType::Call => SpreadType::VerticalCall,
|
||||||
|
OptionType::Put => SpreadType::VerticalPut,
|
||||||
|
};
|
||||||
|
|
||||||
|
SpreadConfig {
|
||||||
|
base,
|
||||||
|
spread_type,
|
||||||
|
leg_configs: vec![
|
||||||
|
LegConfig::new(option_type, long_strike, 1, lot_size),
|
||||||
|
LegConfig::new(option_type, short_strike, -1, lot_size),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::core::types::StopConfig;
|
||||||
|
use crate::core::types::TargetConfig;
|
||||||
|
|
||||||
|
fn sample_data() -> (Vec<i64>, Vec<f64>, Vec<Vec<f64>>, Vec<bool>, Vec<bool>) {
|
||||||
|
let n = 20;
|
||||||
|
let timestamps: Vec<i64> = (0..n as i64).collect();
|
||||||
|
let underlying: Vec<f64> = (100..120).map(|x| x as f64).collect();
|
||||||
|
|
||||||
|
// Call and Put premiums
|
||||||
|
let call_premiums: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect();
|
||||||
|
let put_premiums: Vec<f64> = (0..n).map(|i| 5.0 - (i as f64 * 0.1)).collect();
|
||||||
|
|
||||||
|
let legs_premiums = vec![call_premiums, put_premiums];
|
||||||
|
|
||||||
|
let entries = vec![
|
||||||
|
false, true, false, false, false, false, false, false, false, false, false, false,
|
||||||
|
false, false, false, false, false, false, false, false,
|
||||||
|
];
|
||||||
|
let exits = vec![
|
||||||
|
false, false, false, false, false, false, false, false, false, true, false, false,
|
||||||
|
false, false, false, false, false, false, false, false,
|
||||||
|
];
|
||||||
|
|
||||||
|
(timestamps, underlying, legs_premiums, entries, exits)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_straddle_backtest() {
|
||||||
|
let base_config = BacktestConfig {
|
||||||
|
initial_capital: 100_000.0,
|
||||||
|
fees: 0.001,
|
||||||
|
slippage: 0.0,
|
||||||
|
stop: StopConfig::None,
|
||||||
|
target: TargetConfig::None,
|
||||||
|
upon_bar_close: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = create_straddle_config(base_config, 100.0, 50, true);
|
||||||
|
let backtest = SpreadBacktest::new(config);
|
||||||
|
|
||||||
|
let (timestamps, underlying, legs_premiums, entries, exits) = sample_data();
|
||||||
|
|
||||||
|
let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits);
|
||||||
|
|
||||||
|
assert_eq!(result.trades.len(), 1);
|
||||||
|
assert!(result.equity_curve.len() == timestamps.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iron_condor_backtest() {
|
||||||
|
let base_config = BacktestConfig::default();
|
||||||
|
|
||||||
|
let config = create_iron_condor_config(
|
||||||
|
base_config,
|
||||||
|
95.0, // short put
|
||||||
|
90.0, // long put
|
||||||
|
105.0, // short call
|
||||||
|
110.0, // long call
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
|
||||||
|
let backtest = SpreadBacktest::new(config);
|
||||||
|
|
||||||
|
let n = 20;
|
||||||
|
let timestamps: Vec<i64> = (0..n as i64).collect();
|
||||||
|
let underlying: Vec<f64> = vec![100.0; n];
|
||||||
|
|
||||||
|
// Four legs: short put, long put, short call, long call
|
||||||
|
let legs_premiums = vec![
|
||||||
|
vec![3.0; n], // short put
|
||||||
|
vec![1.5; n], // long put
|
||||||
|
vec![3.0; n], // short call
|
||||||
|
vec![1.5; n], // long call
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut entries = vec![false; n];
|
||||||
|
entries[1] = true;
|
||||||
|
|
||||||
|
let mut exits = vec![false; n];
|
||||||
|
exits[15] = true;
|
||||||
|
|
||||||
|
let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits);
|
||||||
|
|
||||||
|
assert_eq!(result.trades.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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