扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
//! Error types for RaptorBT.
use thiserror::Error;
/// Result type alias for RaptorBT operations.
pub type Result<T> = std::result::Result<T, RaptorError>;
/// Error types for the backtesting engine.
#[derive(Error, Debug)]
pub enum RaptorError {
/// Data length mismatch between arrays.
#[error("Data length mismatch: expected {expected}, got {actual}")]
LengthMismatch { expected: usize, actual: usize },
/// Invalid parameter value.
#[error("Invalid parameter: {message}")]
InvalidParameter { message: String },
/// Insufficient data for calculation.
#[error("Insufficient data: need at least {required} elements, got {available}")]
InsufficientData { required: usize, available: usize },
/// Invalid configuration.
#[error("Invalid configuration: {message}")]
InvalidConfig { message: String },
/// Division by zero error.
#[error("Division by zero in {context}")]
DivisionByZero { context: String },
/// Empty data error.
#[error("Empty data provided for {context}")]
EmptyData { context: String },
/// Invalid index access.
#[error("Index {index} out of bounds for length {length}")]
IndexOutOfBounds { index: usize, length: usize },
/// Python conversion error.
#[error("Python conversion error: {message}")]
PythonError { message: String },
}
impl RaptorError {
/// Create a length mismatch error.
pub fn length_mismatch(expected: usize, actual: usize) -> Self {
Self::LengthMismatch { expected, actual }
}
/// Create an invalid parameter error.
pub fn invalid_parameter(message: impl Into<String>) -> Self {
Self::InvalidParameter { message: message.into() }
}
/// Create an insufficient data error.
pub fn insufficient_data(required: usize, available: usize) -> Self {
Self::InsufficientData { required, available }
}
/// Create an invalid config error.
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::InvalidConfig { message: message.into() }
}
/// Create a division by zero error.
pub fn division_by_zero(context: impl Into<String>) -> Self {
Self::DivisionByZero { context: context.into() }
}
/// Create an empty data error.
pub fn empty_data(context: impl Into<String>) -> Self {
Self::EmptyData { context: context.into() }
}
}
impl From<RaptorError> for pyo3::PyErr {
fn from(err: RaptorError) -> pyo3::PyErr {
pyo3::exceptions::PyValueError::new_err(err.to_string())
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Core types and utilities for RaptorBT.
pub mod error;
pub mod session;
pub mod timeseries;
pub mod types;
pub use error::{RaptorError, Result};
pub use session::{SessionConfig, SessionTracker};
pub use timeseries::TimeSeries;
pub use types::*;
+397
View File
@@ -0,0 +1,397 @@
//! Session tracking for intraday strategies.
//!
//! Handles:
//! - Session boundary detection (market open/close)
//! - Squareoff time enforcement
//! - Session high/low tracking for ORB and session-based indicators
//! - Timezone handling for IST (India Standard Time)
use serde::{Deserialize, Serialize};
/// Session configuration for trading hours.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
/// Market open hour (24-hour format).
pub market_open_hour: u32,
/// Market open minute.
pub market_open_minute: u32,
/// Market close hour (24-hour format).
pub market_close_hour: u32,
/// Market close minute.
pub market_close_minute: u32,
/// Squareoff minutes before market close.
pub squareoff_minutes_before_close: u32,
/// Timezone offset in hours from UTC (5 for IST = UTC+5:30).
pub timezone_offset_hours: i32,
/// Timezone offset minutes (30 for IST).
pub timezone_offset_minutes: i32,
}
impl Default for SessionConfig {
fn default() -> Self {
// Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25)
Self {
market_open_hour: 9,
market_open_minute: 15,
market_close_hour: 15,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
}
impl SessionConfig {
/// Create NSE equity session config (9:15 - 15:30).
pub fn nse_equity() -> Self {
Self::default()
}
/// Create MCX commodity session config (9:00 - 23:30).
pub fn mcx_commodity() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 23,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Create CDS currency session config (9:00 - 17:00).
pub fn cds_currency() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 17,
market_close_minute: 0,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Get market open time in minutes from midnight.
pub fn market_open_minutes(&self) -> u32 {
self.market_open_hour * 60 + self.market_open_minute
}
/// Get market close time in minutes from midnight.
pub fn market_close_minutes(&self) -> u32 {
self.market_close_hour * 60 + self.market_close_minute
}
/// Get squareoff time in minutes from midnight.
pub fn squareoff_minutes(&self) -> u32 {
self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close)
}
/// Get timezone offset in seconds.
pub fn timezone_offset_seconds(&self) -> i64 {
(self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60)
}
}
/// Session tracker for managing intraday session state.
#[derive(Debug, Clone)]
pub struct SessionTracker {
config: SessionConfig,
/// Current session date (days since epoch in local timezone).
current_session_date: i64,
/// Session high price.
session_high: f64,
/// Session low price.
session_low: f64,
/// Session open price.
session_open: f64,
/// Bar index at session start.
session_start_idx: usize,
/// Whether we're currently in a trading session.
in_session: bool,
/// Whether squareoff has been triggered today.
squareoff_triggered: bool,
}
impl SessionTracker {
/// Create a new session tracker.
pub fn new(config: SessionConfig) -> Self {
Self {
config,
current_session_date: -1,
session_high: f64::NEG_INFINITY,
session_low: f64::INFINITY,
session_open: 0.0,
session_start_idx: 0,
in_session: false,
squareoff_triggered: false,
}
}
/// Convert nanosecond timestamp to local time components.
fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) {
// Convert to seconds
let timestamp_s = timestamp_ns / 1_000_000_000;
// Apply timezone offset
let local_s = timestamp_s + self.config.timezone_offset_seconds();
// Calculate date (days since epoch)
let days = local_s / 86400;
// Calculate time within day
let time_in_day = (local_s % 86400) as u32;
let hours = time_in_day / 3600;
let minutes = (time_in_day % 3600) / 60;
let seconds = time_in_day % 60;
(days, hours, minutes, seconds)
}
/// Get minutes from midnight for a timestamp.
fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 {
let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
hours * 60 + minutes
}
/// Check if timestamp is within trading hours.
pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes()
}
/// Check if it's squareoff time.
pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.squareoff_minutes()
}
/// Check if this bar starts a new session.
pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool {
let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns);
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
// New day
if curr_date != prev_date {
let curr_minutes = curr_h * 60 + curr_m;
return curr_minutes >= self.config.market_open_minutes();
}
// Same day, but crossed market open
let prev_minutes = prev_h * 60 + prev_m;
let curr_minutes = curr_h * 60 + curr_m;
prev_minutes < self.config.market_open_minutes()
&& curr_minutes >= self.config.market_open_minutes()
}
/// Check if this bar ends the session.
pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option<i64>) -> bool {
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
let curr_minutes = curr_h * 60 + curr_m;
// At or past market close
if curr_minutes >= self.config.market_close_minutes() {
return true;
}
// Check if next bar is in a new session
if let Some(next_ts) = next_ts_ns {
let (next_date, _, _, _) = self.timestamp_to_local(next_ts);
if next_date != curr_date {
return true;
}
}
false
}
/// Update session state for a new bar.
///
/// Returns tuple of (is_new_session, is_squareoff_time, is_session_end).
pub fn update(
&mut self,
idx: usize,
timestamp_ns: i64,
open: f64,
high: f64,
low: f64,
_close: f64,
prev_timestamp_ns: Option<i64>,
next_timestamp_ns: Option<i64>,
) -> (bool, bool, bool) {
let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
let time_minutes = hours * 60 + minutes;
// Check for new session
let is_new_session = if self.current_session_date != date {
// New date - check if within trading hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
} else if let Some(prev_ts) = prev_timestamp_ns {
if self.is_session_start(prev_ts, timestamp_ns) {
self.reset_session(idx, date, open);
true
} else {
false
}
} else {
// First bar - start session if within hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
};
// Update session high/low
if self.in_session {
if high > self.session_high {
self.session_high = high;
}
if low < self.session_low {
self.session_low = low;
}
}
// Check squareoff time
let is_squareoff =
if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered {
self.squareoff_triggered = true;
self.in_session
} else {
false
};
// Check session end
let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns);
if is_session_end {
self.in_session = false;
}
(is_new_session, is_squareoff, is_session_end)
}
/// Reset session state for a new trading day.
fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) {
self.current_session_date = date;
self.session_start_idx = idx;
self.session_open = open_price;
self.session_high = open_price;
self.session_low = open_price;
self.in_session = true;
self.squareoff_triggered = false;
}
/// Get current session high.
pub fn session_high(&self) -> f64 {
self.session_high
}
/// Get current session low.
pub fn session_low(&self) -> f64 {
self.session_low
}
/// Get current session open.
pub fn session_open(&self) -> f64 {
self.session_open
}
/// Get session start index.
pub fn session_start_idx(&self) -> usize {
self.session_start_idx
}
/// Check if currently in a trading session.
pub fn in_session(&self) -> bool {
self.in_session
}
/// Get opening range (high - low) for the session.
pub fn opening_range(&self) -> f64 {
self.session_high - self.session_low
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
// Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds
// This is approximate for testing
let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64;
let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60;
// Subtract IST offset to get UTC
let utc_seconds = seconds - (5 * 3600 + 30 * 60);
utc_seconds * 1_000_000_000
}
#[test]
fn test_session_config_defaults() {
let config = SessionConfig::default();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_open_minute, 15);
assert_eq!(config.market_close_hour, 15);
assert_eq!(config.market_close_minute, 30);
assert_eq!(config.squareoff_minutes_before_close, 5);
}
#[test]
fn test_squareoff_minutes() {
let config = SessionConfig::default();
// 15:30 - 5 minutes = 15:25 = 925 minutes
assert_eq!(config.squareoff_minutes(), 925);
}
#[test]
fn test_mcx_session() {
let config = SessionConfig::mcx_commodity();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_close_hour, 23);
assert_eq!(config.market_close_minute, 30);
}
#[test]
fn test_session_tracker_new_session() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// Simulate market open at 9:15 IST
let ts = make_timestamp(2024, 1, 15, 9, 15);
let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None);
assert!(is_new);
assert!(tracker.in_session());
assert_eq!(tracker.session_open(), 100.0);
}
#[test]
fn test_session_high_low() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// First bar
let ts1 = make_timestamp(2024, 1, 15, 9, 15);
tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None);
// Second bar
let ts2 = make_timestamp(2024, 1, 15, 9, 30);
tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None);
assert_eq!(tracker.session_high(), 110.0);
assert_eq!(tracker.session_low(), 95.0);
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Time-indexed array wrapper for efficient operations.
use super::types::Timestamp;
/// A time-indexed series of values.
#[derive(Debug, Clone)]
pub struct TimeSeries<T> {
/// Timestamps for each value.
pub timestamps: Vec<Timestamp>,
/// Values.
pub values: Vec<T>,
}
impl<T: Clone> TimeSeries<T> {
/// Create a new time series.
pub fn new(timestamps: Vec<Timestamp>, values: Vec<T>) -> Self {
debug_assert_eq!(timestamps.len(), values.len());
Self { timestamps, values }
}
/// Create from values only (no timestamps).
pub fn from_values(values: Vec<T>) -> Self {
let timestamps = (0..values.len() as i64).collect();
Self { timestamps, values }
}
/// Get the length.
#[inline]
pub fn len(&self) -> usize {
self.values.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
/// Get value at index.
#[inline]
pub fn get(&self, index: usize) -> Option<&T> {
self.values.get(index)
}
/// Get timestamp at index.
#[inline]
pub fn get_timestamp(&self, index: usize) -> Option<Timestamp> {
self.timestamps.get(index).copied()
}
/// Get slice of values.
pub fn slice(&self, start: usize, end: usize) -> Self {
Self {
timestamps: self.timestamps[start..end].to_vec(),
values: self.values[start..end].to_vec(),
}
}
/// Map values to a new type.
pub fn map<U, F>(&self, f: F) -> TimeSeries<U>
where
F: Fn(&T) -> U,
{
TimeSeries {
timestamps: self.timestamps.clone(),
values: self.values.iter().map(f).collect(),
}
}
/// Iterator over (timestamp, value) pairs.
pub fn iter(&self) -> impl Iterator<Item = (Timestamp, &T)> {
self.timestamps.iter().copied().zip(self.values.iter())
}
}
impl<T: Clone + Default> TimeSeries<T> {
/// Create with default values.
pub fn with_default(timestamps: Vec<Timestamp>) -> Self {
let len = timestamps.len();
Self { timestamps, values: vec![T::default(); len] }
}
}
impl TimeSeries<f64> {
/// Create a series filled with NaN.
pub fn with_nan(len: usize) -> Self {
Self { timestamps: (0..len as i64).collect(), values: vec![f64::NAN; len] }
}
/// Calculate sum of all values.
pub fn sum(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).sum()
}
/// Calculate mean of all values.
pub fn mean(&self) -> f64 {
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
if valid.is_empty() {
return f64::NAN;
}
valid.iter().copied().sum::<f64>() / valid.len() as f64
}
/// Calculate standard deviation.
pub fn std(&self) -> f64 {
let mean = self.mean();
if mean.is_nan() {
return f64::NAN;
}
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
if valid.len() < 2 {
return f64::NAN;
}
let variance =
valid.iter().map(|v| (*v - mean).powi(2)).sum::<f64>() / (valid.len() - 1) as f64;
variance.sqrt()
}
/// Get minimum value.
pub fn min(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::INFINITY, f64::min)
}
/// Get maximum value.
pub fn max(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::NEG_INFINITY, f64::max)
}
/// Shift values by n positions (positive = shift forward, fill with NaN).
pub fn shift(&self, n: isize) -> Self {
let len = self.values.len();
let mut result = vec![f64::NAN; len];
if n >= 0 {
let n = n as usize;
if n < len {
for i in n..len {
result[i] = self.values[i - n];
}
}
} else {
let n = (-n) as usize;
if n < len {
for i in 0..len - n {
result[i] = self.values[i + n];
}
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate difference from previous value.
pub fn diff(&self) -> Self {
let mut result = vec![f64::NAN; self.values.len()];
for i in 1..self.values.len() {
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() {
result[i] = self.values[i] - self.values[i - 1];
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate percentage change from previous value.
pub fn pct_change(&self) -> Self {
let mut result = vec![f64::NAN; self.values.len()];
for i in 1..self.values.len() {
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() && self.values[i - 1] != 0.0
{
result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1];
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Apply rolling window function.
pub fn rolling<F>(&self, window: usize, f: F) -> Self
where
F: Fn(&[f64]) -> f64,
{
let mut result = vec![f64::NAN; self.values.len()];
if window == 0 || window > self.values.len() {
return Self { timestamps: self.timestamps.clone(), values: result };
}
for i in (window - 1)..self.values.len() {
let slice = &self.values[i + 1 - window..=i];
result[i] = f(slice);
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate rolling sum.
pub fn rolling_sum(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().sum())
}
/// Calculate rolling mean.
pub fn rolling_mean(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().sum::<f64>() / slice.len() as f64)
}
/// Calculate rolling standard deviation.
pub fn rolling_std(&self, window: usize) -> Self {
self.rolling(window, |slice| {
let mean = slice.iter().sum::<f64>() / slice.len() as f64;
let variance =
slice.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (slice.len() - 1) as f64;
variance.sqrt()
})
}
/// Calculate rolling maximum.
pub fn rolling_max(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().copied().fold(f64::NEG_INFINITY, f64::max))
}
/// Calculate rolling minimum.
pub fn rolling_min(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().copied().fold(f64::INFINITY, f64::min))
}
}
impl TimeSeries<bool> {
/// Count true values.
pub fn count_true(&self) -> usize {
self.values.iter().filter(|&&v| v).count()
}
/// Get indices of true values.
pub fn true_indices(&self) -> Vec<usize> {
self.values
.iter()
.enumerate()
.filter_map(|(i, &v)| if v { Some(i) } else { None })
.collect()
}
/// Logical AND with another series.
pub fn and(&self, other: &Self) -> Self {
debug_assert_eq!(self.len(), other.len());
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a && b).collect(),
}
}
/// Logical OR with another series.
pub fn or(&self, other: &Self) -> Self {
debug_assert_eq!(self.len(), other.len());
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a || b).collect(),
}
}
/// Logical NOT.
pub fn not(&self) -> Self {
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().map(|&v| !v).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_mean() {
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let result = ts.rolling_mean(3);
assert!(result.values[0].is_nan());
assert!(result.values[1].is_nan());
assert!((result.values[2] - 2.0).abs() < 1e-10);
assert!((result.values[3] - 3.0).abs() < 1e-10);
assert!((result.values[4] - 4.0).abs() < 1e-10);
}
#[test]
fn test_shift() {
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let shifted = ts.shift(2);
assert!(shifted.values[0].is_nan());
assert!(shifted.values[1].is_nan());
assert!((shifted.values[2] - 1.0).abs() < 1e-10);
assert!((shifted.values[3] - 2.0).abs() < 1e-10);
assert!((shifted.values[4] - 3.0).abs() < 1e-10);
}
#[test]
fn test_pct_change() {
let ts = TimeSeries::from_values(vec![100.0, 110.0, 99.0]);
let pct = ts.pct_change();
assert!(pct.values[0].is_nan());
assert!((pct.values[1] - 0.1).abs() < 1e-10);
assert!((pct.values[2] - (-0.1)).abs() < 1e-10);
}
}
+595
View File
@@ -0,0 +1,595 @@
//! Core data types for RaptorBT.
use serde::{Deserialize, Serialize};
/// Type alias for price values.
pub type Price = f64;
/// Type alias for timestamp values (nanoseconds since epoch).
pub type Timestamp = i64;
/// Trading direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(i8)]
pub enum Direction {
/// Long position (buy to open, sell to close).
Long = 1,
/// Short position (sell to open, buy to close).
Short = -1,
}
impl Direction {
/// Convert direction to multiplier for P&L calculations.
#[inline]
pub fn multiplier(self) -> f64 {
self as i8 as f64
}
/// Create direction from integer.
pub fn from_int(value: i32) -> Option<Self> {
match value {
1 => Some(Direction::Long),
-1 => Some(Direction::Short),
_ => None,
}
}
}
impl Default for Direction {
fn default() -> Self {
Direction::Long
}
}
/// OHLCV data for a single bar.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct OhlcvBar {
pub timestamp: Timestamp,
pub open: Price,
pub high: Price,
pub low: Price,
pub close: Price,
pub volume: f64,
}
/// OHLCV data series.
#[derive(Debug, Clone)]
pub struct OhlcvData {
pub timestamps: Vec<Timestamp>,
pub open: Vec<Price>,
pub high: Vec<Price>,
pub low: Vec<Price>,
pub close: Vec<Price>,
pub volume: Vec<f64>,
}
impl OhlcvData {
/// Create new OHLCV data from vectors.
pub fn new(
timestamps: Vec<Timestamp>,
open: Vec<Price>,
high: Vec<Price>,
low: Vec<Price>,
close: Vec<Price>,
volume: Vec<f64>,
) -> Self {
Self { timestamps, open, high, low, close, volume }
}
/// Get the number of bars.
#[inline]
pub fn len(&self) -> usize {
self.close.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.close.is_empty()
}
/// Get a single bar at index.
pub fn get_bar(&self, index: usize) -> Option<OhlcvBar> {
if index >= self.len() {
return None;
}
Some(OhlcvBar {
timestamp: self.timestamps[index],
open: self.open[index],
high: self.high[index],
low: self.low[index],
close: self.close[index],
volume: self.volume[index],
})
}
}
/// Raw tick data series for tick-level backtesting.
///
/// All fields are parallel arrays of length N (one entry per tick).
/// `buy_qty_delta` and `sell_qty_delta` must be per-tick deltas, not
/// cumulative session totals — callers are responsible for converting
/// Zerodha-style running sums before passing them here.
#[derive(Debug, Clone)]
pub struct TickData {
/// Nanoseconds-since-epoch timestamp for each tick.
pub timestamps: Vec<Timestamp>,
/// Last traded price at each tick.
pub ltp: Vec<Price>,
/// Best bid price at each tick (0.0 if unavailable).
pub bid: Vec<Price>,
/// Best ask price at each tick (0.0 if unavailable).
pub ask: Vec<Price>,
/// Per-tick buy quantity delta (not cumulative).
pub buy_qty_delta: Vec<f64>,
/// Per-tick sell quantity delta (not cumulative).
pub sell_qty_delta: Vec<f64>,
/// Open interest at each tick (0 if unavailable).
pub oi: Vec<f64>,
}
impl TickData {
/// Number of ticks.
#[inline]
pub fn len(&self) -> usize {
self.ltp.len()
}
/// Whether the series is empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.ltp.is_empty()
}
}
/// Compiled trading signals from strategy.
#[derive(Debug, Clone)]
pub struct CompiledSignals {
/// Symbol identifier.
pub symbol: String,
/// Entry signals (true = enter position).
pub entries: Vec<bool>,
/// Exit signals (true = exit position).
pub exits: Vec<bool>,
/// Optional position sizes (fraction of capital).
pub position_sizes: Option<Vec<f64>>,
/// Trading direction.
pub direction: Direction,
/// Weight for portfolio allocation.
pub weight: f64,
}
impl CompiledSignals {
/// Create new compiled signals.
pub fn new(
symbol: String,
entries: Vec<bool>,
exits: Vec<bool>,
direction: Direction,
weight: f64,
) -> Self {
Self { symbol, entries, exits, position_sizes: None, direction, weight }
}
/// Set position sizes.
pub fn with_position_sizes(mut self, sizes: Vec<f64>) -> Self {
self.position_sizes = Some(sizes);
self
}
/// Get the number of bars.
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// A single executed trade.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trade {
/// Trade identifier.
pub id: u64,
/// Symbol traded.
pub symbol: String,
/// Entry bar index.
pub entry_idx: usize,
/// Exit bar index.
pub exit_idx: usize,
/// Entry price.
pub entry_price: Price,
/// Exit price.
pub exit_price: Price,
/// Position size (number of shares/contracts).
pub size: f64,
/// Trading direction.
pub direction: Direction,
/// Realized profit/loss.
pub pnl: f64,
/// Return percentage.
pub return_pct: f64,
/// Entry timestamp.
pub entry_time: Timestamp,
/// Exit timestamp.
pub exit_time: Timestamp,
/// Fees paid.
pub fees: f64,
/// Exit reason.
pub exit_reason: ExitReason,
}
impl Trade {
/// Check if trade was profitable.
#[inline]
pub fn is_winning(&self) -> bool {
self.pnl > 0.0
}
/// Get holding period in bars.
#[inline]
pub fn holding_period(&self) -> usize {
self.exit_idx - self.entry_idx
}
}
/// Reason for exiting a trade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExitReason {
/// Normal exit signal.
Signal,
/// Stop-loss hit.
StopLoss,
/// Take-profit hit.
TakeProfit,
/// Trailing stop hit.
TrailingStop,
/// End of data.
EndOfData,
/// Option expiry settlement.
Settlement,
/// Max hold time exceeded (tick backtest).
TimeExit,
}
/// Backtest configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestConfig {
/// Initial capital.
pub initial_capital: f64,
/// Transaction fees as fraction (0.001 = 0.1%).
pub fees: f64,
/// Slippage as fraction.
pub slippage: f64,
/// Stop-loss configuration.
pub stop: StopConfig,
/// Take-profit configuration.
pub target: TargetConfig,
/// Whether to execute on bar close.
pub upon_bar_close: bool,
}
impl Default for BacktestConfig {
fn default() -> Self {
Self {
initial_capital: 100_000.0,
fees: 0.001,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
}
}
}
/// Per-instrument configuration for position sizing and risk management.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstrumentConfig {
/// Minimum tradeable quantity (1.0 for NSE EQ, 50.0 for NIFTY F&O, 0.01 for forex).
pub lot_size: Option<f64>,
/// Per-instrument capital cap.
pub alloted_capital: Option<f64>,
/// Per-instrument stop override.
pub stop: Option<StopConfig>,
/// Per-instrument target override.
pub target: Option<TargetConfig>,
/// Existing position quantity (future use).
pub existing_qty: Option<f64>,
/// Existing position average price (future use).
pub avg_price: Option<f64>,
}
impl InstrumentConfig {
/// Round a raw position size down to the nearest lot_size multiple.
/// Returns raw_size unchanged if lot_size is None or <= 0.
pub fn round_to_lot(&self, raw_size: f64) -> f64 {
match self.lot_size {
Some(lot) if lot > 0.0 => (raw_size / lot).floor() * lot,
_ => raw_size,
}
}
}
impl Default for InstrumentConfig {
fn default() -> Self {
Self {
lot_size: None,
alloted_capital: None,
stop: None,
target: None,
existing_qty: None,
avg_price: None,
}
}
}
/// Stop-loss configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum StopConfig {
/// No stop-loss.
None,
/// Fixed percentage stop.
Fixed { percent: f64 },
/// ATR-based stop.
Atr { multiplier: f64, period: usize },
/// Trailing stop.
Trailing { percent: f64 },
}
/// Take-profit configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum TargetConfig {
/// No take-profit.
None,
/// Fixed percentage target.
Fixed { percent: f64 },
/// ATR-based target.
Atr { multiplier: f64, period: usize },
/// Risk-reward ratio target.
RiskReward { ratio: f64 },
}
/// Backtest metrics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BacktestMetrics {
/// Total return percentage.
pub total_return_pct: f64,
/// Sharpe ratio (annualized).
pub sharpe_ratio: f64,
/// Sortino ratio (annualized).
pub sortino_ratio: f64,
/// Calmar ratio.
pub calmar_ratio: f64,
/// Omega ratio.
pub omega_ratio: f64,
/// Maximum drawdown percentage.
pub max_drawdown_pct: f64,
/// Maximum drawdown duration in bars.
pub max_drawdown_duration: usize,
/// Win rate percentage.
pub win_rate_pct: f64,
/// Profit factor.
pub profit_factor: f64,
/// Expectancy (average expected profit per trade).
pub expectancy: f64,
/// System Quality Number (SQN).
pub sqn: f64,
/// Total number of trades.
pub total_trades: usize,
/// Number of closed trades.
pub total_closed_trades: usize,
/// Number of open trades at end.
pub total_open_trades: usize,
/// PnL of open trades.
pub open_trade_pnl: f64,
/// Number of winning trades.
pub winning_trades: usize,
/// Number of losing trades.
pub losing_trades: usize,
/// Starting portfolio value.
pub start_value: f64,
/// Ending portfolio value.
pub end_value: f64,
/// Total fees paid.
pub total_fees_paid: f64,
/// Best trade return percentage.
pub best_trade_pct: f64,
/// Worst trade return percentage.
pub worst_trade_pct: f64,
/// Average trade return percentage.
pub avg_trade_return_pct: f64,
/// Average winning trade return percentage.
pub avg_win_pct: f64,
/// Average losing trade return percentage.
pub avg_loss_pct: f64,
/// Average winning trade duration in bars.
pub avg_winning_duration: f64,
/// Average losing trade duration in bars.
pub avg_losing_duration: f64,
/// Maximum consecutive wins.
pub max_consecutive_wins: usize,
/// Maximum consecutive losses.
pub max_consecutive_losses: usize,
/// Average holding period in bars.
pub avg_holding_period: f64,
/// Exposure time percentage (time in market).
pub exposure_pct: f64,
/// Payoff ratio (avg win / avg loss).
pub payoff_ratio: f64,
/// Recovery factor (net profit / max drawdown).
pub recovery_factor: f64,
}
/// Complete backtest result.
#[derive(Debug, Clone)]
pub struct BacktestResult {
/// Computed metrics.
pub metrics: BacktestMetrics,
/// Equity curve (portfolio value over time).
pub equity_curve: Vec<f64>,
/// Drawdown curve (drawdown percentage over time).
pub drawdown_curve: Vec<f64>,
/// List of executed trades.
pub trades: Vec<Trade>,
/// Daily returns.
pub returns: Vec<f64>,
}
impl BacktestResult {
/// Create a new backtest result.
pub fn new(
metrics: BacktestMetrics,
equity_curve: Vec<f64>,
drawdown_curve: Vec<f64>,
trades: Vec<Trade>,
returns: Vec<f64>,
) -> Self {
Self { metrics, equity_curve, drawdown_curve, trades, returns }
}
}
/// Position state during backtest.
#[derive(Debug, Clone)]
pub struct Position {
/// Whether position is open.
pub is_open: bool,
/// Entry bar index.
pub entry_idx: usize,
/// Entry price.
pub entry_price: Price,
/// Position size.
pub size: f64,
/// Trading direction.
pub direction: Direction,
/// Current stop price.
pub stop_price: Option<Price>,
/// Current target price.
pub target_price: Option<Price>,
/// Highest price since entry (for trailing stops).
pub highest_since_entry: Price,
/// Lowest price since entry (for trailing stops).
pub lowest_since_entry: Price,
/// Entry fees included in trade PnL.
pub entry_fees: f64,
}
impl Position {
/// Create a new closed position state.
pub fn new() -> Self {
Self {
is_open: false,
entry_idx: 0,
entry_price: 0.0,
size: 0.0,
direction: Direction::Long,
stop_price: None,
target_price: None,
highest_since_entry: 0.0,
lowest_since_entry: f64::MAX,
entry_fees: 0.0,
}
}
/// Open a new position.
pub fn open(
&mut self,
idx: usize,
price: Price,
size: f64,
direction: Direction,
stop_price: Option<Price>,
target_price: Option<Price>,
entry_fees: f64,
) {
self.is_open = true;
self.entry_idx = idx;
self.entry_price = price;
self.size = size;
self.direction = direction;
self.stop_price = stop_price;
self.target_price = target_price;
self.highest_since_entry = price;
self.lowest_since_entry = price;
self.entry_fees = entry_fees;
}
/// Close the position.
pub fn close(&mut self) {
self.is_open = false;
}
/// Update highest/lowest prices for trailing stops.
pub fn update_extremes(&mut self, high: Price, low: Price) {
if high > self.highest_since_entry {
self.highest_since_entry = high;
}
if low < self.lowest_since_entry {
self.lowest_since_entry = low;
}
}
/// Calculate unrealized P&L at given price.
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
if !self.is_open {
return 0.0;
}
let price_change = current_price - self.entry_price;
price_change * self.size * self.direction.multiplier()
}
}
impl Default for Position {
fn default() -> Self {
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);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Fee calculation models.
use crate::core::types::{Direction, Price};
/// Fee model for calculating transaction costs.
#[derive(Debug, Clone)]
pub enum FeeModel {
/// No fees.
None,
/// Fixed percentage of trade value.
Percentage(f64),
/// Fixed fee per trade.
Fixed(f64),
/// Per-share/contract fee.
PerShare(f64),
/// Tiered fee structure based on trade value.
Tiered(Vec<(f64, f64)>), // (threshold, rate)
/// Custom fee function (stored as percentage for simplicity).
Custom { base: f64, per_share: f64 },
}
impl Default for FeeModel {
fn default() -> Self {
FeeModel::Percentage(0.001) // 0.1% default
}
}
impl FeeModel {
/// Create a new percentage fee model.
pub fn percentage(rate: f64) -> Self {
FeeModel::Percentage(rate)
}
/// Create a new fixed fee model.
pub fn fixed(amount: f64) -> Self {
FeeModel::Fixed(amount)
}
/// Create a new per-share fee model.
pub fn per_share(rate: f64) -> Self {
FeeModel::PerShare(rate)
}
/// Calculate fee for a trade.
///
/// # Arguments
/// * `price` - Trade price
/// * `size` - Position size (shares/contracts)
/// * `direction` - Trade direction (for asymmetric fees if needed)
///
/// # Returns
/// Fee amount
pub fn calculate(&self, price: Price, size: f64, _direction: Direction) -> f64 {
let trade_value = price * size.abs();
match self {
FeeModel::None => 0.0,
FeeModel::Percentage(rate) => trade_value * rate,
FeeModel::Fixed(amount) => *amount,
FeeModel::PerShare(rate) => size.abs() * rate,
FeeModel::Tiered(tiers) => {
// Find applicable tier
let mut applicable_rate = 0.0;
for (threshold, rate) in tiers {
if trade_value >= *threshold {
applicable_rate = *rate;
} else {
break;
}
}
trade_value * applicable_rate
}
FeeModel::Custom { base, per_share } => base + size.abs() * per_share,
}
}
/// Calculate round-trip fees (entry + exit).
pub fn round_trip(
&self,
entry_price: Price,
exit_price: Price,
size: f64,
direction: Direction,
) -> f64 {
self.calculate(entry_price, size, direction) + self.calculate(exit_price, size, direction)
}
}
/// Broker-specific fee configurations.
pub struct BrokerFees;
impl BrokerFees {
/// Interactive Brokers tiered pricing (approximate).
pub fn interactive_brokers() -> FeeModel {
FeeModel::Custom { base: 1.0, per_share: 0.005 }
}
/// Zero commission broker (like Robinhood).
pub fn zero_commission() -> FeeModel {
FeeModel::None
}
/// Indian broker (Zerodha-like).
pub fn india_equity() -> FeeModel {
// 0.03% or Rs 20 per trade, whichever is lower
// Simplified as 0.03%
FeeModel::Percentage(0.0003)
}
/// Crypto exchange (typical).
pub fn crypto_exchange() -> FeeModel {
FeeModel::Percentage(0.001) // 0.1% maker/taker
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percentage_fee() {
let fee = FeeModel::percentage(0.001);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 10.0).abs() < 1e-10); // 100 * 100 * 0.001 = 10
}
#[test]
fn test_fixed_fee() {
let fee = FeeModel::fixed(5.0);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 5.0).abs() < 1e-10);
}
#[test]
fn test_per_share_fee() {
let fee = FeeModel::per_share(0.01);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 1.0).abs() < 1e-10); // 100 * 0.01 = 1
}
#[test]
fn test_round_trip() {
let fee = FeeModel::percentage(0.001);
let result = fee.round_trip(100.0, 110.0, 100.0, Direction::Long);
// Entry: 100 * 100 * 0.001 = 10
// Exit: 110 * 100 * 0.001 = 11
// Total: 21
assert!((result - 21.0).abs() < 1e-10);
}
#[test]
fn test_no_fee() {
let fee = FeeModel::None;
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 0.0).abs() < 1e-10);
}
}
+361
View File
@@ -0,0 +1,361 @@
//! Order fill simulation models.
use crate::core::types::{Direction, OhlcvBar, Price};
/// Fill price model determining at what price orders are executed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FillPrice {
/// Execute at close price (end of bar).
Close,
/// Execute at open price (start of next bar).
Open,
/// Execute at OHLC average.
Average,
/// Execute at typical price (H+L+C)/3.
Typical,
/// Execute at VWAP (if available, otherwise typical).
Vwap,
/// Execute at worst price (high for buys, low for sells).
Worst,
/// Execute at best price (low for buys, high for sells).
Best,
}
impl Default for FillPrice {
fn default() -> Self {
FillPrice::Close
}
}
impl FillPrice {
/// Get execution price from OHLCV bar.
///
/// # Arguments
/// * `bar` - OHLCV bar data
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Execution price
pub fn get_price(&self, bar: &OhlcvBar, direction: Direction, is_entry: bool) -> Price {
match self {
FillPrice::Close => bar.close,
FillPrice::Open => bar.open,
FillPrice::Average => (bar.open + bar.high + bar.low + bar.close) / 4.0,
FillPrice::Typical => (bar.high + bar.low + bar.close) / 3.0,
FillPrice::Vwap => (bar.high + bar.low + bar.close) / 3.0, // Simplified
FillPrice::Worst => {
// Worst price for the trade
match (direction, is_entry) {
(Direction::Long, true) => bar.high, // Buy high
(Direction::Long, false) => bar.low, // Sell low
(Direction::Short, true) => bar.low, // Short at low (bad)
(Direction::Short, false) => bar.high, // Cover at high (bad)
}
}
FillPrice::Best => {
// Best price for the trade
match (direction, is_entry) {
(Direction::Long, true) => bar.low, // Buy low
(Direction::Long, false) => bar.high, // Sell high
(Direction::Short, true) => bar.high, // Short at high (good)
(Direction::Short, false) => bar.low, // Cover at low (good)
}
}
}
}
/// Get execution price from separate arrays.
///
/// # Arguments
/// * `open` - Open price
/// * `high` - High price
/// * `low` - Low price
/// * `close` - Close price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Execution price
pub fn get_price_from_arrays(
&self,
open: Price,
high: Price,
low: Price,
close: Price,
direction: Direction,
is_entry: bool,
) -> Price {
match self {
FillPrice::Close => close,
FillPrice::Open => open,
FillPrice::Average => (open + high + low + close) / 4.0,
FillPrice::Typical => (high + low + close) / 3.0,
FillPrice::Vwap => (high + low + close) / 3.0,
FillPrice::Worst => match (direction, is_entry) {
(Direction::Long, true) => high,
(Direction::Long, false) => low,
(Direction::Short, true) => low,
(Direction::Short, false) => high,
},
FillPrice::Best => match (direction, is_entry) {
(Direction::Long, true) => low,
(Direction::Long, false) => high,
(Direction::Short, true) => high,
(Direction::Short, false) => low,
},
}
}
}
/// Fill model combining price model with execution rules.
#[derive(Debug, Clone)]
pub struct FillModel {
/// Price model for fills.
pub fill_price: FillPrice,
/// Whether to delay execution to next bar.
pub delay_to_next_bar: bool,
/// Partial fill ratio (1.0 = full fill).
pub fill_ratio: f64,
}
impl Default for FillModel {
fn default() -> Self {
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
}
}
impl FillModel {
/// Create a fill model that executes at close.
pub fn at_close() -> Self {
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
}
/// Create a fill model that executes at next bar's open.
pub fn at_next_open() -> Self {
Self { fill_price: FillPrice::Open, delay_to_next_bar: true, fill_ratio: 1.0 }
}
/// Set partial fill ratio.
pub fn with_fill_ratio(mut self, ratio: f64) -> Self {
self.fill_ratio = ratio.clamp(0.0, 1.0);
self
}
/// Check if a limit order would be filled.
///
/// # Arguments
/// * `limit_price` - Limit price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// True if order would be filled
pub fn would_fill_limit(
&self,
limit_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> bool {
match (direction, is_entry) {
// Long entry: buy at or below limit
(Direction::Long, true) => bar.low <= limit_price,
// Long exit: sell at or above limit
(Direction::Long, false) => bar.high >= limit_price,
// Short entry: sell at or above limit
(Direction::Short, true) => bar.high >= limit_price,
// Short exit: buy at or below limit
(Direction::Short, false) => bar.low <= limit_price,
}
}
/// Get fill price for a limit order.
///
/// Returns limit price if filled, None if not filled.
///
/// # Arguments
/// * `limit_price` - Limit price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Fill price or None
pub fn get_limit_fill_price(
&self,
limit_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> Option<Price> {
if self.would_fill_limit(limit_price, bar, direction, is_entry) {
// For limit orders, fill at limit price (or better if gap)
Some(limit_price)
} else {
None
}
}
/// Check if a stop order would be triggered.
///
/// # Arguments
/// * `stop_price` - Stop price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// True if stop would be triggered
pub fn would_trigger_stop(
&self,
stop_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> bool {
match (direction, is_entry) {
// Long entry stop: buy when price rises to stop
(Direction::Long, true) => bar.high >= stop_price,
// Long exit stop: sell when price falls to stop
(Direction::Long, false) => bar.low <= stop_price,
// Short entry stop: sell when price falls to stop
(Direction::Short, true) => bar.low <= stop_price,
// Short exit stop: buy when price rises to stop
(Direction::Short, false) => bar.high >= stop_price,
}
}
/// Get fill price for a stop order.
///
/// Returns fill price if triggered, None if not.
/// Uses worst-case scenario (stop price or worse).
///
/// # Arguments
/// * `stop_price` - Stop price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Fill price or None
pub fn get_stop_fill_price(
&self,
stop_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> Option<Price> {
if !self.would_trigger_stop(stop_price, bar, direction, is_entry) {
return None;
}
// Check for gap through stop
match (direction, is_entry) {
(Direction::Long, true) => {
// Buy stop: fill at stop or worse (gap up through stop)
if bar.open >= stop_price {
Some(bar.open) // Gap up, fill at open
} else {
Some(stop_price)
}
}
(Direction::Long, false) => {
// Sell stop: fill at stop or worse (gap down through stop)
if bar.open <= stop_price {
Some(bar.open) // Gap down, fill at open
} else {
Some(stop_price)
}
}
(Direction::Short, true) => {
// Short stop: fill at stop or worse (gap down through stop)
if bar.open <= stop_price {
Some(bar.open)
} else {
Some(stop_price)
}
}
(Direction::Short, false) => {
// Cover stop: fill at stop or worse (gap up through stop)
if bar.open >= stop_price {
Some(bar.open)
} else {
Some(stop_price)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_bar() -> OhlcvBar {
OhlcvBar { timestamp: 0, open: 100.0, high: 105.0, low: 95.0, close: 102.0, volume: 1000.0 }
}
#[test]
fn test_fill_price_close() {
let bar = test_bar();
let fp = FillPrice::Close;
assert!((fp.get_price(&bar, Direction::Long, true) - 102.0).abs() < 1e-10);
}
#[test]
fn test_fill_price_worst() {
let bar = test_bar();
let fp = FillPrice::Worst;
// Long entry: high (105)
assert!((fp.get_price(&bar, Direction::Long, true) - 105.0).abs() < 1e-10);
// Long exit: low (95)
assert!((fp.get_price(&bar, Direction::Long, false) - 95.0).abs() < 1e-10);
}
#[test]
fn test_limit_fill() {
let fill = FillModel::default();
let bar = test_bar();
// Limit buy at 96 should fill (low is 95)
assert!(fill.would_fill_limit(96.0, &bar, Direction::Long, true));
// Limit buy at 94 should not fill (low is 95)
assert!(!fill.would_fill_limit(94.0, &bar, Direction::Long, true));
}
#[test]
fn test_stop_fill() {
let fill = FillModel::default();
let bar = test_bar();
// Stop sell at 96 should trigger (low is 95)
assert!(fill.would_trigger_stop(96.0, &bar, Direction::Long, false));
// Stop sell at 94 should not trigger (low is 95)
assert!(!fill.would_trigger_stop(94.0, &bar, Direction::Long, false));
}
#[test]
fn test_gap_through_stop() {
let fill = FillModel::default();
// Gap down through stop
let gap_bar = OhlcvBar {
timestamp: 0,
open: 90.0, // Gap down from stop at 95
high: 92.0,
low: 88.0,
close: 91.0,
volume: 1000.0,
};
let fill_price = fill.get_stop_fill_price(95.0, &gap_bar, Direction::Long, false);
// Should fill at open (90) not stop (95)
assert_eq!(fill_price, Some(90.0));
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Order execution simulation for RaptorBT.
pub mod fees;
pub mod fill;
pub mod slippage;
pub use fees::FeeModel;
pub use fill::{FillModel, FillPrice};
pub use slippage::SlippageModel;
+204
View File
@@ -0,0 +1,204 @@
//! Slippage models for realistic trade execution.
use crate::core::types::{Direction, Price};
/// Slippage model for simulating execution price deviation.
#[derive(Debug, Clone)]
pub enum SlippageModel {
/// No slippage.
None,
/// Fixed percentage slippage.
Percentage(f64),
/// Fixed point slippage.
Fixed(f64),
/// Volume-based slippage (higher volume = lower slippage).
VolumeBased { base: f64, volume_factor: f64 },
/// Spread-based slippage (uses bid-ask spread).
SpreadBased { half_spread: f64 },
}
impl Default for SlippageModel {
fn default() -> Self {
SlippageModel::None
}
}
impl SlippageModel {
/// Create a new percentage slippage model.
pub fn percentage(rate: f64) -> Self {
SlippageModel::Percentage(rate)
}
/// Create a new fixed slippage model.
pub fn fixed(points: f64) -> Self {
SlippageModel::Fixed(points)
}
/// Create a volume-based slippage model.
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
SlippageModel::VolumeBased { base, volume_factor }
}
/// Calculate slippage for a trade.
///
/// For long entries and short exits: slippage is ADDED to price (pay more/receive less)
/// For short entries and long exits: slippage is SUBTRACTED from price
///
/// # Arguments
/// * `price` - Base execution price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
/// * `volume` - Optional volume for volume-based models
///
/// # Returns
/// Slippage amount (positive = unfavorable)
pub fn calculate(
&self,
price: Price,
direction: Direction,
is_entry: bool,
volume: Option<f64>,
) -> f64 {
let base_slippage = match self {
SlippageModel::None => 0.0,
SlippageModel::Percentage(rate) => price * rate,
SlippageModel::Fixed(points) => *points,
SlippageModel::VolumeBased { base, volume_factor } => {
if let Some(vol) = volume {
if vol > 0.0 {
base * (1.0 / (1.0 + vol * volume_factor))
} else {
*base
}
} else {
*base
}
}
SlippageModel::SpreadBased { half_spread } => *half_spread,
};
// Determine sign based on trade type
// Long entry: pay higher price (positive slippage)
// Long exit: receive lower price (negative slippage)
// Short entry: receive higher price (negative slippage means worse)
// Short exit: pay higher price
match (direction, is_entry) {
(Direction::Long, true) => base_slippage, // Pay more
(Direction::Long, false) => -base_slippage, // Receive less
(Direction::Short, true) => -base_slippage, // Receive less
(Direction::Short, false) => base_slippage, // Pay more
}
}
/// Apply slippage to get execution price.
///
/// # Arguments
/// * `price` - Base price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
/// * `volume` - Optional volume for volume-based models
///
/// # Returns
/// Execution price after slippage
pub fn apply(
&self,
price: Price,
direction: Direction,
is_entry: bool,
volume: Option<f64>,
) -> Price {
price + self.calculate(price, direction, is_entry, volume)
}
}
/// Market impact model for large orders.
#[derive(Debug, Clone)]
pub struct MarketImpact {
/// Temporary impact coefficient.
pub temporary_impact: f64,
/// Permanent impact coefficient.
pub permanent_impact: f64,
/// Average daily volume for normalization.
pub avg_daily_volume: f64,
}
impl MarketImpact {
/// Create a new market impact model.
pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self {
Self { temporary_impact: temporary, permanent_impact: permanent, avg_daily_volume: adv }
}
/// Calculate market impact for an order.
///
/// Uses simplified square-root model: impact = sigma * sqrt(Q / ADV)
///
/// # Arguments
/// * `order_size` - Number of shares/contracts
/// * `price` - Current price
/// * `volatility` - Price volatility (sigma)
///
/// # Returns
/// Total market impact in price terms
pub fn calculate(&self, order_size: f64, price: Price, volatility: f64) -> f64 {
if self.avg_daily_volume <= 0.0 {
return 0.0;
}
let participation_rate = order_size / self.avg_daily_volume;
let sqrt_participation = participation_rate.sqrt();
let temporary = self.temporary_impact * volatility * price * sqrt_participation;
let permanent = self.permanent_impact * volatility * price * participation_rate;
temporary + permanent
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percentage_slippage() {
let slip = SlippageModel::percentage(0.001);
// Long entry: pay more
let entry_slip = slip.calculate(100.0, Direction::Long, true, None);
assert!((entry_slip - 0.1).abs() < 1e-10);
// Long exit: receive less
let exit_slip = slip.calculate(100.0, Direction::Long, false, None);
assert!((exit_slip - (-0.1)).abs() < 1e-10);
}
#[test]
fn test_apply_slippage() {
let slip = SlippageModel::percentage(0.001);
// Long entry at 100 should pay 100.1
let entry_price = slip.apply(100.0, Direction::Long, true, None);
assert!((entry_price - 100.1).abs() < 1e-10);
// Long exit at 100 should receive 99.9
let exit_price = slip.apply(100.0, Direction::Long, false, None);
assert!((exit_price - 99.9).abs() < 1e-10);
}
#[test]
fn test_no_slippage() {
let slip = SlippageModel::None;
let result = slip.apply(100.0, Direction::Long, true, None);
assert!((result - 100.0).abs() < 1e-10);
}
#[test]
fn test_volume_based_slippage() {
let slip = SlippageModel::volume_based(0.1, 0.0001);
// High volume should have lower slippage
let high_vol = slip.calculate(100.0, Direction::Long, true, Some(100000.0));
let low_vol = slip.calculate(100.0, Direction::Long, true, Some(1000.0));
assert!(high_vol < low_vol);
}
}
+847
View File
@@ -0,0 +1,847 @@
use crate::core::error::RaptorError;
use crate::core::Result;
pub struct AroonResult {
pub up: Vec<f64>,
pub down: Vec<f64>,
}
pub struct AdxAllResult {
pub adx: Vec<f64>,
pub plus_di: Vec<f64>,
pub minus_di: Vec<f64>,
}
fn ema_nan_safe(data: &[f64], period: usize) -> Vec<f64> {
let n = data.len();
let mut result = vec![f64::NAN; n];
if period == 0 || n < period {
return result;
}
let k = 2.0 / (period as f64 + 1.0);
let mut seed_sum = 0.0;
let mut seed_count = 0usize;
let mut first_valid = None;
for i in 0..n {
if !data[i].is_nan() {
seed_sum += data[i];
seed_count += 1;
if seed_count == period {
first_valid = Some(i);
result[i] = seed_sum / period as f64;
break;
}
}
}
if let Some(start) = first_valid {
for i in (start + 1)..n {
if !data[i].is_nan() {
result[i] = data[i] * k + result[i - 1] * (1.0 - k);
}
}
}
result
}
pub fn cci(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("CCI period must be > 0"));
}
Ok(ferro_ta_core::momentum::cci(high, low, close, period))
}
pub fn willr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Williams %R period must be > 0"));
}
Ok(ferro_ta_core::momentum::willr(high, low, close, period))
}
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Result<Vec<f64>> {
if acceleration <= 0.0 || maximum <= 0.0 {
return Err(RaptorError::invalid_parameter(
"SAR acceleration and maximum must be > 0",
));
}
Ok(ferro_ta_core::overlap::sar(high, low, acceleration, maximum))
}
pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("+DI period must be > 0"));
}
Ok(ferro_ta_core::momentum::plus_di(high, low, close, period))
}
pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("-DI period must be > 0"));
}
Ok(ferro_ta_core::momentum::minus_di(high, low, close, period))
}
pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<AdxAllResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ADX period must be > 0"));
}
let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) =
ferro_ta_core::momentum::adx_all(high, low, close, period);
Ok(AdxAllResult { adx, plus_di, minus_di })
}
pub fn adxr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ADXR period must be > 0"));
}
Ok(ferro_ta_core::momentum::adxr(high, low, close, period))
}
pub fn roc(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ROC period must be > 0"));
}
Ok(ferro_ta_core::momentum::roc(close, period))
}
pub fn mfi(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
period: usize,
) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("MFI period must be > 0"));
}
Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period))
}
pub fn wma(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("WMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::wma(close, period))
}
pub fn dema(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("DEMA period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
for i in 0..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() {
result[i] = 2.0 * ema1[i] - ema2[i];
}
}
Ok(result)
}
pub fn tema(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TEMA period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
let ema3 = ema_nan_safe(&ema2, period);
for i in 0..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
}
}
Ok(result)
}
pub fn kama(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("KAMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::kama(close, period))
}
pub fn stochrsi(
close: &[f64],
timeperiod: usize,
fastk_period: usize,
fastd_period: usize,
) -> Result<(Vec<f64>, Vec<f64>)> {
if timeperiod == 0 {
return Err(RaptorError::invalid_parameter(
"StochRSI timeperiod must be > 0",
));
}
Ok(ferro_ta_core::momentum::stochrsi(
close,
timeperiod,
fastk_period,
fastd_period,
))
}
pub fn aroon(high: &[f64], low: &[f64], period: usize) -> Result<AroonResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Aroon period must be > 0"));
}
let (down, up) = ferro_ta_core::momentum::aroon(high, low, period);
Ok(AroonResult { up, down })
}
pub fn trix(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TRIX period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
let ema3 = ema_nan_safe(&ema2, period);
for i in 1..n {
let prev = ema3[i - 1];
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
result[i] = (ema3[i] - prev) / prev * 100.0;
}
}
Ok(result)
}
pub fn natr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("NATR period must be > 0"));
}
Ok(ferro_ta_core::volatility::natr(high, low, close, period))
}
pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volatility::trange(high, low, close))
}
pub fn stddev(real: &[f64], period: usize, nbdev: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("StdDev period must be > 0"));
}
Ok(ferro_ta_core::statistic::stddev(real, period, nbdev))
}
pub fn var(real: &[f64], period: usize, nbdev: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("VAR period must be > 0"));
}
Ok(ferro_ta_core::statistic::var(real, period, nbdev))
}
pub fn linearreg(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg(close, period))
}
pub fn linearreg_slope(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Slope period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_slope(close, period))
}
pub fn beta(real0: &[f64], real1: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Beta period must be > 0"));
}
Ok(ferro_ta_core::statistic::beta(real0, real1, period))
}
pub fn correl(real0: &[f64], real1: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Correl period must be > 0"));
}
Ok(ferro_ta_core::statistic::correl(real0, real1, period))
}
pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volume::ad(high, low, close, volume))
}
pub fn adosc(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
fastperiod: usize,
slowperiod: usize,
) -> Result<Vec<f64>> {
if fastperiod == 0 || slowperiod == 0 {
return Err(RaptorError::invalid_parameter(
"ADOSC fast/slow period must be > 0",
));
}
Ok(ferro_ta_core::volume::adosc(
high, low, close, volume, fastperiod, slowperiod,
))
}
pub fn obv(close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volume::obv(close, volume))
}
pub fn mom(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Momentum period must be > 0"));
}
Ok(ferro_ta_core::momentum::mom(close, period))
}
pub struct PpoResult {
pub ppo_line: Vec<f64>,
pub signal_line: Vec<f64>,
pub histogram: Vec<f64>,
}
pub fn ppo(
close: &[f64],
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> Result<PpoResult> {
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 {
return Err(RaptorError::invalid_parameter(
"PPO fast/slow/signal period must be > 0",
));
}
let n = close.len();
let fast_ema = ferro_ta_core::overlap::ema(close, fastperiod);
let slow_ema = ferro_ta_core::overlap::ema(close, slowperiod);
let mut ppo_line = vec![f64::NAN; n];
for i in 0..n {
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() && slow_ema[i] != 0.0 {
ppo_line[i] = (fast_ema[i] - slow_ema[i]) / slow_ema[i] * 100.0;
}
}
let signal_line = ema_nan_safe(&ppo_line, signalperiod);
let mut histogram = vec![f64::NAN; n];
for i in 0..n {
if !ppo_line[i].is_nan() && !signal_line[i].is_nan() {
histogram[i] = ppo_line[i] - signal_line[i];
}
}
Ok(PpoResult { ppo_line, signal_line, histogram })
}
pub fn cmo(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("CMO period must be > 0"));
}
Ok(ferro_ta_core::momentum::cmo(close, period))
}
pub fn aroonosc(high: &[f64], low: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Aroon Osc period must be > 0",
));
}
Ok(ferro_ta_core::momentum::aroonosc(high, low, period))
}
pub fn bop(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Vec<f64>> {
Ok(ferro_ta_core::momentum::bop(open, high, low, close))
}
pub fn ultosc(
high: &[f64],
low: &[f64],
close: &[f64],
period1: usize,
period2: usize,
period3: usize,
) -> Result<Vec<f64>> {
if period1 == 0 || period2 == 0 || period3 == 0 {
return Err(RaptorError::invalid_parameter(
"Ultimate Osc periods must be > 0",
));
}
Ok(ferro_ta_core::momentum::ultosc(high, low, close, period1, period2, period3))
}
pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::typprice(high, low, close))
}
pub fn medprice(high: &[f64], low: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::medprice(high, low))
}
pub fn avgprice(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::avgprice(open, high, low, close))
}
pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::wclprice(high, low, close))
}
pub fn midpoint(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Midpoint period must be > 0"));
}
Ok(ferro_ta_core::overlap::midpoint(close, period))
}
pub fn midprice(high: &[f64], low: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Midprice period must be > 0"));
}
Ok(ferro_ta_core::overlap::midprice(high, low, period))
}
pub fn t3(close: &[f64], period: usize, vfactor: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("T3 period must be > 0"));
}
Ok(ferro_ta_core::overlap::t3(close, period, vfactor))
}
pub fn trima(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TRIMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::trima(close, period))
}
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Result<Vec<f64>> {
if fastperiod == 0 || slowperiod == 0 {
return Err(RaptorError::invalid_parameter(
"APO fast/slow period must be > 0",
));
}
Ok(ferro_ta_core::momentum::apo(close, fastperiod, slowperiod))
}
pub fn tsf(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TSF period must be > 0"));
}
Ok(ferro_ta_core::statistic::tsf(close, period))
}
pub fn linearreg_angle(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Angle period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_angle(close, period))
}
pub fn linearreg_intercept(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Intercept period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_intercept(close, period))
}
// ============================================================================
// Extended indicators (from ferro_ta_core::extended)
// ============================================================================
/// Volume-Weighted Moving Average.
///
/// # Returns
/// Vector of VWMA values (NaN for warmup period).
pub fn vwma(close: &[f64], volume: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("VWMA period must be > 0"));
}
Ok(ferro_ta_core::extended::vwma(close, volume, period))
}
/// Donchian Channels result.
pub struct DonchianResult {
pub upper: Vec<f64>,
pub middle: Vec<f64>,
pub lower: Vec<f64>,
}
/// Donchian Channels — rolling highest high / lowest low.
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn donchian(high: &[f64], low: &[f64], period: usize) -> Result<DonchianResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Donchian period must be > 0"));
}
let (upper, middle, lower) = ferro_ta_core::extended::donchian(high, low, period);
Ok(DonchianResult { upper, middle, lower })
}
/// Choppiness Index — measures choppy vs trending market (0 = trend, 100 = chop).
///
/// # Returns
/// Vector of CI values (NaN for warmup period).
pub fn choppiness_index(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Choppiness period must be > 0",
));
}
Ok(ferro_ta_core::extended::choppiness_index(high, low, close, period))
}
/// Hull Moving Average.
///
/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`.
pub fn hull_ma(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Hull MA period must be > 0"));
}
Ok(ferro_ta_core::extended::hull_ma(close, period))
}
/// Chandelier Exit result — ATR-based trailing stops.
pub struct ChandelierResult {
pub long_exit: Vec<f64>,
pub short_exit: Vec<f64>,
}
/// Chandelier Exit — ATR-based trailing stop levels.
///
/// # Returns
/// `(long_exit, short_exit)` arrays.
pub fn chandelier_exit(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
multiplier: f64,
) -> Result<ChandelierResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Chandelier period must be > 0",
));
}
if multiplier <= 0.0 {
return Err(RaptorError::invalid_parameter(
"Chandelier multiplier must be > 0",
));
}
let (long_exit, short_exit) =
ferro_ta_core::extended::chandelier_exit(high, low, close, period, multiplier);
Ok(ChandelierResult { long_exit, short_exit })
}
/// Ichimoku Cloud (Ichimoku Kinko Hyo) result.
pub struct IchimokuResult {
pub tenkan: Vec<f64>,
pub kijun: Vec<f64>,
pub senkou_a: Vec<f64>,
pub senkou_b: Vec<f64>,
pub chikou: Vec<f64>,
}
/// Ichimoku Cloud — `(tenkan, kijun, senkou_a, senkou_b, chikou)`.
pub fn ichimoku(
high: &[f64],
low: &[f64],
close: &[f64],
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> Result<IchimokuResult> {
if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 {
return Err(RaptorError::invalid_parameter(
"Ichimoku periods must be > 0",
));
}
let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku(
high,
low,
close,
tenkan_period,
kijun_period,
senkou_b_period,
displacement,
);
Ok(IchimokuResult { tenkan, kijun, senkou_a, senkou_b, chikou })
}
/// Pivot Points result — supports classic / fibonacci / camarilla.
pub struct PivotPointsResult {
pub pivot: Vec<f64>,
pub r1: Vec<f64>,
pub s1: Vec<f64>,
pub r2: Vec<f64>,
pub s2: Vec<f64>,
}
/// Pivot Points — classic, fibonacci, or camarilla methodology.
///
/// `method` must be one of: `"classic"`, `"fibonacci"`, `"camarilla"`.
/// Index 0 of each array is always NaN (no previous bar).
pub fn pivot_points(
high: &[f64],
low: &[f64],
close: &[f64],
method: &str,
) -> Result<PivotPointsResult> {
let m = method.to_lowercase();
if !matches!(m.as_str(), "classic" | "fibonacci" | "camarilla") {
return Err(RaptorError::invalid_parameter(
"Pivot method must be 'classic', 'fibonacci', or 'camarilla'",
));
}
let (pivot, r1, s1, r2, s2) =
ferro_ta_core::extended::pivot_points(high, low, close, &m);
Ok(PivotPointsResult { pivot, r1, s1, r2, s2 })
}
// ============================================================================
// Cycle (Hilbert Transform) indicators (from ferro_ta_core::cycle)
// ============================================================================
/// Hilbert Transform — Instantaneous Trendline.
pub fn ht_trendline(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Trendline requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_trendline(close))
}
/// Hilbert Transform — Dominant Cycle Period.
pub fn ht_dcperiod(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT DCPeriod requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_dcperiod(close))
}
/// Hilbert Transform — Dominant Cycle Phase.
pub fn ht_dcphase(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT DCPhase requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_dcphase(close))
}
/// Hilbert Transform — Phasor Components result.
pub struct HtPhasorResult {
pub in_phase: Vec<f64>,
pub quadrature: Vec<f64>,
}
/// Hilbert Transform — Phasor Components `(in_phase, quadrature)`.
pub fn ht_phasor(close: &[f64]) -> Result<HtPhasorResult> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Phasor requires at least 32 bars",
));
}
let (in_phase, quadrature) = ferro_ta_core::cycle::ht_phasor(close);
Ok(HtPhasorResult { in_phase, quadrature })
}
/// Hilbert Transform — Sine Wave result.
pub struct HtSineResult {
pub sine: Vec<f64>,
pub lead_sine: Vec<f64>,
}
/// Hilbert Transform — Sine Wave `(sine, lead_sine)`.
pub fn ht_sine(close: &[f64]) -> Result<HtSineResult> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Sine requires at least 32 bars",
));
}
let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close);
Ok(HtSineResult { sine, lead_sine })
}
/// Hilbert Transform — Trend vs Cycle Mode.
///
/// Returns `Vec<i32>`: `1` = trend mode, `0` = cycle mode.
pub fn ht_trendmode(close: &[f64]) -> Result<Vec<i32>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT TrendMode requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_trendmode(close))
}
// ============================================================================
// Market regime detection (from ferro_ta_core::regime)
// ============================================================================
/// Trend/range regime labels based on ADX threshold.
///
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = warmup/NaN.
pub fn regime_adx(adx: &[f64], threshold: f64) -> Result<Vec<i8>> {
Ok(ferro_ta_core::regime::regime_adx(adx, threshold))
}
/// Trend/range regime using ADX + ATR-ratio rule.
///
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = NaN.
pub fn regime_combined(
adx: &[f64],
atr: &[f64],
close: &[f64],
adx_threshold: f64,
atr_pct_threshold: f64,
) -> Result<Vec<i8>> {
Ok(ferro_ta_core::regime::regime_combined(
adx,
atr,
close,
adx_threshold,
atr_pct_threshold,
))
}
/// Detect structural breaks via CUSUM test.
///
/// Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
pub fn detect_breaks_cusum(
series: &[f64],
window: usize,
threshold: f64,
slack: f64,
) -> Result<Vec<i8>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"CUSUM window must be >= 2",
));
}
Ok(ferro_ta_core::regime::detect_breaks_cusum(
series, window, threshold, slack,
))
}
/// Detect volatility regime breaks using rolling variance ratio.
///
/// `long_window` must be > `short_window`. Returns `Vec<i8>`:
/// `1` at break bars, `0` elsewhere.
pub fn rolling_variance_break(
series: &[f64],
short_window: usize,
long_window: usize,
threshold: f64,
) -> Result<Vec<i8>> {
if short_window < 2 {
return Err(RaptorError::invalid_parameter(
"Variance break short_window must be >= 2",
));
}
if long_window <= short_window {
return Err(RaptorError::invalid_parameter(
"Variance break long_window must be > short_window",
));
}
Ok(ferro_ta_core::regime::rolling_variance_break(
series,
short_window,
long_window,
threshold,
))
}
// ============================================================================
// Portfolio / cross-series tools (from ferro_ta_core::portfolio)
// ============================================================================
/// Rolling beta — `cov(asset, benchmark) / var(benchmark)` over a sliding window.
pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Result<Vec<f64>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"Rolling beta window must be >= 2",
));
}
Ok(ferro_ta_core::portfolio::rolling_beta(asset, benchmark, window))
}
/// Drawdown series result.
pub struct DrawdownResult {
/// Per-bar drawdown as a non-positive fraction of peak equity.
pub series: Vec<f64>,
/// Maximum drawdown over the full input range (non-positive).
pub max_drawdown: f64,
}
/// Drawdown series — `(per_bar_drawdown, max_drawdown)` from an equity curve.
pub fn drawdown_series(equity: &[f64]) -> Result<DrawdownResult> {
let (series, max_drawdown) = ferro_ta_core::portfolio::drawdown_series(equity);
Ok(DrawdownResult { series, max_drawdown })
}
/// Rolling Z-Score over a window.
pub fn zscore_series(x: &[f64], window: usize) -> Result<Vec<f64>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"Z-score window must be >= 2",
));
}
Ok(ferro_ta_core::portfolio::zscore_series(x, window))
}
/// Relative strength — `asset - beta * benchmark` (excess return style).
pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Result<Vec<f64>> {
if asset_returns.len() != benchmark_returns.len() {
return Err(RaptorError::invalid_parameter(
"Relative strength inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::relative_strength(asset_returns, benchmark_returns))
}
/// Spread between two series: `a - hedge * b`.
pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Result<Vec<f64>> {
if a.len() != b.len() {
return Err(RaptorError::invalid_parameter(
"Spread inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::spread(a, b, hedge))
}
/// Ratio between two series element-wise: `a / b`.
pub fn ratio(a: &[f64], b: &[f64]) -> Result<Vec<f64>> {
if a.len() != b.len() {
return Err(RaptorError::invalid_parameter(
"Ratio inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::ratio(a, b))
}
+36
View File
@@ -0,0 +1,36 @@
//! Technical indicators for RaptorBT.
//!
//! All indicators are implemented as pure functions that take slice inputs
//! and return Vec outputs. NaN values are used for the warmup period.
pub mod ferro_bridge;
pub mod momentum;
pub mod rolling;
pub mod strength;
pub mod tick_features;
pub mod trend;
pub mod volatility;
pub mod volume;
pub use ferro_bridge::{
AdxAllResult, AroonResult, ChandelierResult, DonchianResult, DrawdownResult, HtPhasorResult,
HtSineResult, IchimokuResult, PivotPointsResult, PpoResult, ad, adosc, adx_all, aroon,
aroonosc, apo, avgprice, beta, bop, cci, chandelier_exit, choppiness_index, correl, dema,
detect_breaks_cusum, donchian, drawdown_series, ht_dcperiod, ht_dcphase, ht_phasor,
ht_sine, ht_trendline, ht_trendmode, hull_ma, ichimoku, kama, linearreg, linearreg_angle,
linearreg_intercept, linearreg_slope, medprice, mfi, midpoint, midprice, minus_di, mom,
natr, obv, pivot_points, plus_di, ppo, ratio, regime_adx, regime_combined,
relative_strength, roc, rolling_beta, rolling_variance_break, sar, spread, stddev,
stochrsi, t3, tema, trange, trix, trima, tsf, typprice, ultosc, var, vwma, wclprice, willr,
wma, zscore_series,
};
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
pub use rolling::{rolling_max, rolling_min};
pub use strength::adx;
pub use tick_features::{
buy_sell_imbalance_delta, oi_position_pct, realized_vol_rolling, return_window, spread_pct,
tick_velocity,
};
pub use trend::{ema, sma, supertrend, SupertrendResult};
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
pub use volume::{obv as obv_native, vwap};
+147
View File
@@ -0,0 +1,147 @@
//! Momentum indicators: RSI, MACD, Stochastic.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Relative Strength Index (RSI).
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of RSI values (0-100 scale, NaN for warmup period)
pub fn rsi(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("RSI period must be > 0"));
}
Ok(ferro_ta_core::momentum::rsi(data, period))
}
/// MACD result structure.
#[derive(Debug, Clone)]
pub struct MacdResult {
/// MACD line (fast EMA - slow EMA).
pub macd_line: Vec<f64>,
/// Signal line (EMA of MACD line).
pub signal_line: Vec<f64>,
/// Histogram (MACD line - signal line).
pub histogram: Vec<f64>,
}
/// Moving Average Convergence Divergence (MACD).
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `fast_period` - Fast EMA period (default: 12)
/// * `slow_period` - Slow EMA period (default: 26)
/// * `signal_period` - Signal line EMA period (default: 9)
///
/// # Returns
/// MacdResult with MACD line, signal line, and histogram
pub fn macd(
data: &[f64],
fast_period: usize,
slow_period: usize,
signal_period: usize,
) -> Result<MacdResult> {
if fast_period == 0 || slow_period == 0 || signal_period == 0 {
return Err(RaptorError::invalid_parameter("MACD periods must be > 0"));
}
if fast_period >= slow_period {
return Err(RaptorError::invalid_parameter("MACD fast period must be < slow period"));
}
let (macd_line, signal_line, histogram) =
ferro_ta_core::overlap::macd(data, fast_period, slow_period, signal_period);
Ok(MacdResult { macd_line, signal_line, histogram })
}
/// Stochastic oscillator result.
#[derive(Debug, Clone)]
pub struct StochasticResult {
/// %K line (fast stochastic).
pub k: Vec<f64>,
/// %D line (slow stochastic, SMA of %K).
pub d: Vec<f64>,
}
/// Stochastic Oscillator.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `k_period` - %K lookback period (default: 14)
/// * `d_period` - %D smoothing period (default: 3)
///
/// # Returns
/// StochasticResult with %K and %D lines (0-100 scale)
pub fn stochastic(
high: &[f64],
low: &[f64],
close: &[f64],
k_period: usize,
d_period: usize,
) -> Result<StochasticResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if k_period == 0 || d_period == 0 {
return Err(RaptorError::invalid_parameter("Stochastic periods must be > 0"));
}
let (k, d) = ferro_ta_core::momentum::stoch(high, low, close, k_period, d_period, d_period);
Ok(StochasticResult { k, d })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rsi() {
// Test with simple increasing data
let data = vec![
44.0, 44.25, 44.5, 43.75, 44.5, 44.25, 44.0, 44.0, 44.25, 45.0, 45.5, 46.0, 46.5, 47.0,
47.5,
];
let result = rsi(&data, 14).unwrap();
// RSI should be valid from index 14
assert!(result[13].is_nan());
assert!(!result[14].is_nan());
assert!(result[14] >= 0.0 && result[14] <= 100.0);
}
#[test]
fn test_macd() {
let data: Vec<f64> = (1..=50).map(|x| x as f64).collect();
let result = macd(&data, 12, 26, 9).unwrap();
// MACD line should be valid from index 25 (slow_period - 1)
assert!(result.macd_line[24].is_nan());
assert!(!result.macd_line[25].is_nan());
// Signal line starts at index slow_period-1 + signal_period-1 = 25+8 = 33
assert!(result.signal_line[32].is_nan());
assert!(!result.signal_line[33].is_nan());
}
#[test]
fn test_stochastic() {
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
let result = stochastic(&high, &low, &close, 5, 3).unwrap();
// %K should be valid from index 4
assert!(result.k[3].is_nan());
assert!(!result.k[4].is_nan());
assert!(result.k[4] >= 0.0 && result.k[4] <= 100.0);
// %D should be valid from index 6
assert!(result.d[5].is_nan());
assert!(!result.d[6].is_nan());
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Rolling min/max indicators for LLV/HHV support.
//!
//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV)
//! and Highest High Value (HHV) expressions.
use crate::core::error::RaptorError;
/// Calculate rolling minimum (Lowest Low Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling minimum values
pub fn rolling_min(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let min_val =
data[start..=i]
.iter()
.fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) });
result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val };
}
Ok(result)
}
/// Calculate rolling maximum (Highest High Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling maximum values
pub fn rolling_max(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let max_val =
data[start..=i]
.iter()
.fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) });
result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val };
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_min() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_min(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8)
assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2)
assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7)
assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1)
assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9)
}
#[test]
fn test_rolling_max() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_max(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8)
assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2)
assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7)
assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1)
assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9)
}
#[test]
fn test_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
assert!(rolling_min(&data, 0).is_err());
assert!(rolling_max(&data, 0).is_err());
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Strength indicators: ADX.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Average Directional Index (ADX).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of ADX values (0-100 scale, NaN for warmup period)
pub fn adx(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("ADX period must be > 0"));
}
Ok(ferro_ta_core::momentum::adx(high, low, close, period))
}
/// Directional Index result including +DI, -DI, and ADX.
#[derive(Debug, Clone)]
pub struct DirectionalIndexResult {
/// +DI values.
pub plus_di: Vec<f64>,
/// -DI values.
pub minus_di: Vec<f64>,
/// ADX values.
pub adx: Vec<f64>,
}
/// Full Directional Movement System (DI+, DI-, ADX).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// DirectionalIndexResult with +DI, -DI, and ADX
pub fn directional_movement(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
) -> Result<DirectionalIndexResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("Period must be > 0"));
}
let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) =
ferro_ta_core::momentum::adx_all(high, low, close, period);
Ok(DirectionalIndexResult { plus_di, minus_di, adx })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adx() {
// Generate some trending data
let n = 50;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let result = adx(&high, &low, &close, 14).unwrap();
// ADX should be valid from index 27 (2 * period - 1)
assert!(result[26].is_nan());
assert!(!result[27].is_nan());
// ADX should be positive and <= 100
assert!(result[27] >= 0.0 && result[27] <= 100.0);
}
#[test]
fn test_directional_movement() {
let n = 50;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let result = directional_movement(&high, &low, &close, 14).unwrap();
// Check DI values are valid
assert!(!result.plus_di[20].is_nan());
assert!(!result.minus_di[20].is_nan());
// In an uptrend, +DI should be greater than -DI
assert!(result.plus_di[40] > result.minus_di[40]);
}
}
+246
View File
@@ -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 (60300 s at ~80 ticks/min
/// = 80400 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, &ltp, 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);
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Trend indicators: SMA, EMA, Supertrend.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Simple Moving Average.
///
/// # Arguments
/// * `data` - Price data
/// * `period` - Lookback period
///
/// # Returns
/// Vector of SMA values (NaN for warmup period)
pub fn sma(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("SMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::sma(data, period))
}
/// Exponential Moving Average.
///
/// # Arguments
/// * `data` - Price data
/// * `period` - Lookback period (used to calculate smoothing factor)
///
/// # Returns
/// Vector of EMA values (NaN for warmup period)
pub fn ema(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("EMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::ema(data, period))
}
/// EMA with custom smoothing factor (internal use).
#[allow(dead_code)]
pub(crate) fn ema_with_alpha(data: &[f64], alpha: f64, initial: f64) -> Vec<f64> {
let n = data.len();
let mut result = vec![f64::NAN; n];
if n == 0 {
return result;
}
result[0] = initial;
for i in 1..n {
if data[i].is_nan() {
result[i] = result[i - 1];
} else {
result[i] = alpha * data[i] + (1.0 - alpha) * result[i - 1];
}
}
result
}
/// Supertrend indicator result.
#[derive(Debug, Clone)]
pub struct SupertrendResult {
/// Supertrend line values.
pub supertrend: Vec<f64>,
/// Direction: 1 = bullish (below price), -1 = bearish (above price).
pub direction: Vec<i8>,
}
/// Supertrend indicator.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - ATR period
/// * `multiplier` - ATR multiplier
///
/// # Returns
/// SupertrendResult with supertrend line and direction
pub fn supertrend(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
multiplier: f64,
) -> Result<SupertrendResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("Supertrend period must be > 0"));
}
let mut supertrend = vec![f64::NAN; n];
let mut direction = vec![0i8; n];
if period >= n {
return Ok(SupertrendResult { supertrend, direction });
}
// Calculate ATR
let atr_values = super::volatility::atr(high, low, close, period)?;
// Calculate basic upper and lower bands
let mut upper_band = vec![f64::NAN; n];
let mut lower_band = vec![f64::NAN; n];
for i in (period - 1)..n {
let hl2 = (high[i] + low[i]) / 2.0;
let atr_val = atr_values[i];
if !atr_val.is_nan() {
upper_band[i] = hl2 + multiplier * atr_val;
lower_band[i] = hl2 - multiplier * atr_val;
}
}
// Calculate final bands with carryover logic
let mut final_upper = vec![f64::NAN; n];
let mut final_lower = vec![f64::NAN; n];
for i in (period - 1)..n {
if i == period - 1 {
final_upper[i] = upper_band[i];
final_lower[i] = lower_band[i];
} else {
// Final upper band: use lower of current upper or previous final upper
// if previous close was below previous final upper
if !upper_band[i].is_nan() && !final_upper[i - 1].is_nan() {
if close[i - 1] <= final_upper[i - 1] {
final_upper[i] = upper_band[i].min(final_upper[i - 1]);
} else {
final_upper[i] = upper_band[i];
}
} else {
final_upper[i] = upper_band[i];
}
// Final lower band: use higher of current lower or previous final lower
// if previous close was above previous final lower
if !lower_band[i].is_nan() && !final_lower[i - 1].is_nan() {
if close[i - 1] >= final_lower[i - 1] {
final_lower[i] = lower_band[i].max(final_lower[i - 1]);
} else {
final_lower[i] = lower_band[i];
}
} else {
final_lower[i] = lower_band[i];
}
}
}
// Calculate supertrend and direction
for i in (period - 1)..n {
if i == period - 1 {
// Initial direction based on price vs bands
if close[i] <= final_upper[i] {
supertrend[i] = final_upper[i];
direction[i] = -1; // bearish
} else {
supertrend[i] = final_lower[i];
direction[i] = 1; // bullish
}
} else {
let _prev_st = supertrend[i - 1];
let prev_dir = direction[i - 1];
if prev_dir == 1 {
// Was bullish
if close[i] < final_lower[i] {
// Switch to bearish
supertrend[i] = final_upper[i];
direction[i] = -1;
} else {
// Stay bullish
supertrend[i] = final_lower[i];
direction[i] = 1;
}
} else {
// Was bearish
if close[i] > final_upper[i] {
// Switch to bullish
supertrend[i] = final_lower[i];
direction[i] = 1;
} else {
// Stay bearish
supertrend[i] = final_upper[i];
direction[i] = -1;
}
}
}
}
Ok(SupertrendResult { supertrend, direction })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sma() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = sma(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 2.0).abs() < 1e-10);
assert!((result[3] - 3.0).abs() < 1e-10);
assert!((result[4] - 4.0).abs() < 1e-10);
}
#[test]
fn test_ema() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = ema(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!(!result[2].is_nan());
assert!(!result[3].is_nan());
assert!(!result[4].is_nan());
// EMA should be between min and max of data
assert!(result[4] >= 1.0 && result[4] <= 5.0);
}
#[test]
fn test_sma_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
let result = sma(&data, 0);
assert!(result.is_err());
}
#[test]
fn test_ema_period_larger_than_data() {
let data = vec![1.0, 2.0, 3.0];
let result = ema(&data, 10).unwrap();
assert!(result.iter().all(|v| v.is_nan()));
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Volatility indicators: ATR, Bollinger Bands.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Average True Range (ATR).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of ATR values (NaN for warmup period)
pub fn atr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("ATR period must be > 0"));
}
Ok(ferro_ta_core::volatility::atr(high, low, close, period))
}
/// True Range calculation (single bar).
#[inline]
pub fn true_range(high: f64, low: f64, prev_close: f64) -> f64 {
let hl = high - low;
let hc = (high - prev_close).abs();
let lc = (low - prev_close).abs();
hl.max(hc).max(lc)
}
/// Bollinger Bands result.
#[derive(Debug, Clone)]
pub struct BollingerBandsResult {
/// Middle band (SMA).
pub middle: Vec<f64>,
/// Upper band (SMA + std_dev * multiplier).
pub upper: Vec<f64>,
/// Lower band (SMA - std_dev * multiplier).
pub lower: Vec<f64>,
/// Bandwidth: (upper - lower) / middle.
pub bandwidth: Vec<f64>,
/// %B: (price - lower) / (upper - lower).
pub percent_b: Vec<f64>,
}
/// Bollinger Bands.
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `period` - Lookback period (default: 20)
/// * `std_dev` - Standard deviation multiplier (default: 2.0)
///
/// # Returns
/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B
pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<BollingerBandsResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Bollinger Bands period must be > 0"));
}
if std_dev <= 0.0 {
return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0"));
}
let n = data.len();
let (upper, middle, lower) = ferro_ta_core::overlap::bbands(data, period, std_dev, std_dev);
let mut bandwidth = vec![f64::NAN; n];
let mut percent_b = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && middle[i].abs() > f64::EPSILON {
bandwidth[i] = (upper[i] - lower[i]) / middle[i].abs();
}
let band_width = upper[i] - lower[i];
if band_width > f64::EPSILON {
percent_b[i] = (data[i] - lower[i]) / band_width;
}
}
Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b })
}
/// Keltner Channels (ATR-based bands).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `ema_period` - EMA period for middle band
/// * `atr_period` - ATR period
/// * `multiplier` - ATR multiplier
///
/// # Returns
/// Tuple of (middle, upper, lower) bands
pub fn keltner_channels(
high: &[f64],
low: &[f64],
close: &[f64],
ema_period: usize,
atr_period: usize,
multiplier: f64,
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>)> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
// Calculate EMA for middle band
let middle = super::trend::ema(close, ema_period)?;
// Calculate ATR
let atr_values = atr(high, low, close, atr_period)?;
// Calculate bands
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && !atr_values[i].is_nan() {
upper[i] = middle[i] + multiplier * atr_values[i];
lower[i] = middle[i] - multiplier * atr_values[i];
}
}
Ok((middle, upper, lower))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atr() {
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
let result = atr(&high, &low, &close, 5).unwrap();
// ATR should be valid from index 4
assert!(result[3].is_nan());
assert!(!result[4].is_nan());
assert!(result[4] > 0.0);
}
#[test]
fn test_bollinger_bands() {
let data: Vec<f64> = (1..=30).map(|x| x as f64 + (x as f64 * 0.1).sin()).collect();
let result = bollinger_bands(&data, 20, 2.0).unwrap();
// Bands should be valid from index 19
assert!(result.middle[18].is_nan());
assert!(!result.middle[19].is_nan());
// Upper > Middle > Lower
assert!(result.upper[19] > result.middle[19]);
assert!(result.middle[19] > result.lower[19]);
// %B should be between 0 and 1 for data within bands
assert!(result.percent_b[19] >= -0.5 && result.percent_b[19] <= 1.5);
}
#[test]
fn test_true_range() {
// Simple case
assert!((true_range(52.0, 48.0, 50.0) - 4.0).abs() < 1e-10);
// Gap up case
assert!((true_range(55.0, 53.0, 50.0) - 5.0).abs() < 1e-10);
// Gap down case
assert!((true_range(48.0, 45.0, 50.0) - 5.0).abs() < 1e-10);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Volume indicators: VWAP, OBV.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Volume Weighted Average Price (VWAP).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of VWAP values
pub fn vwap(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if n == 0 {
return Ok(vec![]);
}
let mut result = vec![f64::NAN; n];
let mut cumulative_tp_vol = 0.0;
let mut cumulative_vol = 0.0;
for i in 0..n {
// Typical price
let tp = (high[i] + low[i] + close[i]) / 3.0;
cumulative_tp_vol += tp * volume[i];
cumulative_vol += volume[i];
if cumulative_vol > 0.0 {
result[i] = cumulative_tp_vol / cumulative_vol;
}
}
Ok(result)
}
/// VWAP with session reset (e.g., daily reset).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
/// * `session_starts` - Boolean array indicating session start (true = reset VWAP)
///
/// # Returns
/// Vector of VWAP values with session resets
pub fn vwap_session(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
session_starts: &[bool],
) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() || n != session_starts.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if n == 0 {
return Ok(vec![]);
}
let mut result = vec![f64::NAN; n];
let mut cumulative_tp_vol = 0.0;
let mut cumulative_vol = 0.0;
for i in 0..n {
// Reset on session start
if session_starts[i] {
cumulative_tp_vol = 0.0;
cumulative_vol = 0.0;
}
// Typical price
let tp = (high[i] + low[i] + close[i]) / 3.0;
cumulative_tp_vol += tp * volume[i];
cumulative_vol += volume[i];
if cumulative_vol > 0.0 {
result[i] = cumulative_tp_vol / cumulative_vol;
}
}
Ok(result)
}
/// On Balance Volume (OBV).
///
/// # Arguments
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of OBV values
pub fn obv(close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != volume.len() {
return Err(RaptorError::length_mismatch(n, volume.len()));
}
Ok(ferro_ta_core::volume::obv(close, volume))
}
/// Volume Rate of Change.
///
/// # Arguments
/// * `volume` - Volume data
/// * `period` - Lookback period
///
/// # Returns
/// Vector of volume rate of change values
pub fn volume_roc(volume: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Period must be > 0"));
}
let n = volume.len();
let mut result = vec![f64::NAN; n];
if period >= n {
return Ok(result);
}
for i in period..n {
if volume[i - period] != 0.0 {
result[i] = (volume[i] - volume[i - period]) / volume[i - period] * 100.0;
}
}
Ok(result)
}
/// Money Flow Index (volume-weighted RSI).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of MFI values (0-100 scale)
pub fn mfi(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
period: usize,
) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("MFI period must be > 0"));
}
Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period))
}
/// Accumulation/Distribution Line.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of A/D line values
pub fn ad_line(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
Ok(ferro_ta_core::volume::ad(high, low, close, volume))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vwap() {
let high = vec![52.0, 53.0, 54.0, 53.0, 52.0];
let low = vec![50.0, 51.0, 52.0, 51.0, 50.0];
let close = vec![51.0, 52.0, 53.0, 52.0, 51.0];
let volume = vec![1000.0, 1500.0, 2000.0, 1500.0, 1000.0];
let result = vwap(&high, &low, &close, &volume).unwrap();
// VWAP should be valid for all bars
assert!(!result[0].is_nan());
assert!(!result[4].is_nan());
// VWAP should be between low and high range
assert!(result[4] >= 50.0 && result[4] <= 54.0);
}
#[test]
fn test_obv() {
let close = vec![50.0, 51.0, 50.5, 52.0, 51.0];
let volume = vec![1000.0, 1500.0, 1200.0, 1800.0, 1300.0];
let result = obv(&close, &volume).unwrap();
// OBV starts with first volume
assert!((result[0] - 1000.0).abs() < 1e-10);
// Price up -> add volume
assert!((result[1] - 2500.0).abs() < 1e-10);
// Price down -> subtract volume
assert!((result[2] - 1300.0).abs() < 1e-10);
}
#[test]
fn test_mfi() {
let high = vec![
52.0, 53.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0,
56.0,
];
let low = vec![
50.0, 51.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0,
54.0,
];
let close = vec![
51.0, 52.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0,
55.0,
];
let volume = vec![1000.0; 15];
let result = mfi(&high, &low, &close, &volume, 14).unwrap();
// MFI should be valid from index 14
assert!(result[13].is_nan());
assert!(!result[14].is_nan());
assert!(result[14] >= 0.0 && result[14] <= 100.0);
}
}
+160
View File
@@ -0,0 +1,160 @@
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
#![allow(non_local_definitions)]
//! RaptorBT - High-performance Rust backtesting engine.
//!
//! This crate provides a complete backtesting solution with:
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
//! - Portfolio simulation engine
//! - Multiple strategy types (single, basket, options, pairs, multi)
//! - Stop-loss and take-profit mechanisms
//! - Streaming metrics calculation
use pyo3::prelude::*;
pub mod core;
pub mod execution;
pub mod indicators;
pub mod metrics;
pub mod portfolio;
pub mod python;
pub mod signals;
pub mod stops;
pub mod strategies;
/// Python module entry point
#[pymodule]
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// Register config classes
m.add_class::<python::bindings::PyBacktestConfig>()?;
m.add_class::<python::bindings::PyInstrumentConfig>()?;
m.add_class::<python::bindings::PyStopConfig>()?;
m.add_class::<python::bindings::PyTargetConfig>()?;
// Register result classes
m.add_class::<python::bindings::PyBacktestResult>()?;
m.add_class::<python::bindings::PyBacktestMetrics>()?;
m.add_class::<python::bindings::PyTrade>()?;
// Register backtest functions
m.add_function(wrap_pyfunction!(python::bindings::run_single_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_basket_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_multi_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_tick_backtest, m)?)?;
// Register batch spread backtest
m.add_class::<python::bindings::PyBatchSpreadItem>()?;
m.add_function(wrap_pyfunction!(python::bindings::batch_spread_backtest, m)?)?;
// Register Monte Carlo simulation
m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?;
// Register tick signal functions
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?;
// Register tick feature functions
m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?;
// Register indicator functions
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rsi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::macd, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stochastic, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::atr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::bollinger_bands, 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::supertrend, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?;
// Extended indicators (via ferro_ta_core)
m.add_function(wrap_pyfunction!(python::bindings::cci, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::willr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::sar, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::plus_di, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::minus_di, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adx_all, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adxr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::roc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::mfi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::wma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::dema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::kama, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stochrsi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::aroon, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trix, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::natr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trange, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stddev, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::var, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_slope, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_intercept, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_angle, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tsf, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::beta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::correl, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ad, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::obv, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::mom, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ppo, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::cmo, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::aroonosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::bop, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ultosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::typprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::medprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::avgprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::wclprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::midpoint, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::midprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::t3, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trima, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::apo, m)?)?;
// P0 batch — Extended (donchian / hull_ma / ichimoku / pivot_points / etc.)
m.add_function(wrap_pyfunction!(python::bindings::vwma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::donchian, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::choppiness_index, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::hull_ma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::chandelier_exit, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ichimoku, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::pivot_points, m)?)?;
// P0 batch — Hilbert Transform (cycle)
m.add_function(wrap_pyfunction!(python::bindings::ht_trendline, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_dcperiod, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_dcphase, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_phasor, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_sine, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_trendmode, m)?)?;
// P0 batch — Market regime detection
m.add_function(wrap_pyfunction!(python::bindings::regime_adx, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::regime_combined, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::detect_breaks_cusum, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_variance_break, m)?)?;
// P0 batch — Portfolio / cross-series tools
m.add_function(wrap_pyfunction!(python::bindings::rolling_beta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::drawdown_series, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::zscore_series, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::relative_strength, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::spread, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ratio, m)?)?;
Ok(())
}
+344
View File
@@ -0,0 +1,344 @@
//! Incremental drawdown tracking.
/// Drawdown tracker for incremental portfolio value updates.
#[derive(Debug, Clone)]
pub struct DrawdownTracker {
/// Current peak value.
peak: f64,
/// Current drawdown value.
current_drawdown: f64,
/// Maximum drawdown seen.
max_drawdown: f64,
/// Current drawdown duration (bars since peak).
current_duration: usize,
/// Maximum drawdown duration.
max_duration: usize,
/// Value at drawdown start.
drawdown_start_value: f64,
/// Index at drawdown start.
drawdown_start_idx: usize,
/// Index at max drawdown.
max_drawdown_idx: usize,
/// Total count of updates.
count: usize,
}
impl Default for DrawdownTracker {
fn default() -> Self {
Self::new()
}
}
impl DrawdownTracker {
/// Create a new drawdown tracker.
pub fn new() -> Self {
Self {
peak: 0.0,
current_drawdown: 0.0,
max_drawdown: 0.0,
current_duration: 0,
max_duration: 0,
drawdown_start_value: 0.0,
drawdown_start_idx: 0,
max_drawdown_idx: 0,
count: 0,
}
}
/// Create with initial value.
pub fn with_initial(initial_value: f64) -> Self {
Self {
peak: initial_value,
current_drawdown: 0.0,
max_drawdown: 0.0,
current_duration: 0,
max_duration: 0,
drawdown_start_value: initial_value,
drawdown_start_idx: 0,
max_drawdown_idx: 0,
count: 1,
}
}
/// Update with new portfolio value.
pub fn update(&mut self, value: f64) {
self.count += 1;
if value > self.peak {
// New peak - reset drawdown
self.peak = value;
self.current_drawdown = 0.0;
self.current_duration = 0;
self.drawdown_start_value = value;
self.drawdown_start_idx = self.count - 1;
} else {
// In drawdown
self.current_drawdown = (self.peak - value) / self.peak;
self.current_duration += 1;
if self.current_drawdown > self.max_drawdown {
self.max_drawdown = self.current_drawdown;
self.max_drawdown_idx = self.count - 1;
}
if self.current_duration > self.max_duration {
self.max_duration = self.current_duration;
}
}
}
/// Get current drawdown as percentage.
#[inline]
pub fn current_drawdown_pct(&self) -> f64 {
self.current_drawdown * 100.0
}
/// Get maximum drawdown as percentage.
#[inline]
pub fn max_drawdown_pct(&self) -> f64 {
self.max_drawdown * 100.0
}
/// Get maximum drawdown as fraction.
#[inline]
pub fn max_drawdown(&self) -> f64 {
self.max_drawdown
}
/// Get current peak value.
#[inline]
pub fn peak(&self) -> f64 {
self.peak
}
/// Get current drawdown duration.
#[inline]
pub fn current_duration(&self) -> usize {
self.current_duration
}
/// Get maximum drawdown duration.
#[inline]
pub fn max_duration(&self) -> usize {
self.max_duration
}
/// Check if currently in drawdown.
#[inline]
pub fn in_drawdown(&self) -> bool {
self.current_drawdown > 0.0
}
/// Get index where max drawdown occurred.
#[inline]
pub fn max_drawdown_idx(&self) -> usize {
self.max_drawdown_idx
}
/// Reset the tracker.
pub fn reset(&mut self) {
*self = Self::new();
}
}
/// Calculate drawdown curve from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Drawdown percentages at each point
pub fn calculate_drawdown_curve(equity_curve: &[f64]) -> Vec<f64> {
let n = equity_curve.len();
if n == 0 {
return vec![];
}
let mut drawdown_curve = vec![0.0; n];
let mut peak = equity_curve[0];
for i in 0..n {
if equity_curve[i] > peak {
peak = equity_curve[i];
}
if peak > 0.0 {
drawdown_curve[i] = (peak - equity_curve[i]) / peak * 100.0;
}
}
drawdown_curve
}
/// Calculate maximum drawdown from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Maximum drawdown as percentage
pub fn max_drawdown(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
dd.iter().fold(0.0f64, |a, &b| a.max(b))
}
/// Calculate average drawdown from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Average drawdown as percentage
pub fn avg_drawdown(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
if dd.is_empty() {
return 0.0;
}
dd.iter().sum::<f64>() / dd.len() as f64
}
/// Find drawdown periods.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Vector of (start_idx, end_idx, max_drawdown) tuples for each drawdown period
pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> {
let n = equity_curve.len();
if n < 2 {
return vec![];
}
let mut periods = Vec::new();
let mut peak = equity_curve[0];
let mut peak_idx = 0;
let mut in_dd = false;
let mut dd_start = 0;
let mut max_dd = 0.0;
for i in 1..n {
if equity_curve[i] > peak {
if in_dd {
// End of drawdown period
periods.push((dd_start, i - 1, max_dd));
in_dd = false;
max_dd = 0.0;
}
peak = equity_curve[i];
peak_idx = i;
} else if peak > 0.0 {
let dd = (peak - equity_curve[i]) / peak * 100.0;
if !in_dd && dd > 0.0 {
in_dd = true;
dd_start = peak_idx;
}
if dd > max_dd {
max_dd = dd;
}
}
}
// Handle ongoing drawdown at end
if in_dd {
periods.push((dd_start, n - 1, max_dd));
}
periods
}
/// Calculate Calmar ratio.
///
/// # Arguments
/// * `total_return` - Total return as percentage
/// * `max_drawdown` - Maximum drawdown as percentage
///
/// # Returns
/// Calmar ratio
pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 {
if max_drawdown <= 0.0 {
return if total_return > 0.0 { f64::INFINITY } else { 0.0 };
}
total_return / max_drawdown
}
/// Calculate Ulcer Index (root mean square of drawdowns).
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Ulcer Index
pub fn ulcer_index(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
if dd.is_empty() {
return 0.0;
}
let sum_sq: f64 = dd.iter().map(|d| d * d).sum();
(sum_sq / dd.len() as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_tracking() {
let mut tracker = DrawdownTracker::new();
tracker.update(100.0);
tracker.update(110.0);
tracker.update(105.0); // 4.5% drawdown
tracker.update(120.0);
tracker.update(100.0); // 16.67% drawdown
assert!((tracker.max_drawdown_pct() - 16.67).abs() < 0.1);
assert!((tracker.peak() - 120.0).abs() < 1e-10);
}
#[test]
fn test_drawdown_curve() {
let equity = vec![100.0, 110.0, 105.0, 120.0, 100.0];
let dd = calculate_drawdown_curve(&equity);
assert_eq!(dd.len(), 5);
assert!((dd[0] - 0.0).abs() < 1e-10);
assert!((dd[1] - 0.0).abs() < 1e-10);
assert!((dd[2] - 4.545).abs() < 0.1); // (110-105)/110 * 100
assert!((dd[3] - 0.0).abs() < 1e-10);
assert!((dd[4] - 16.67).abs() < 0.1); // (120-100)/120 * 100
}
#[test]
fn test_max_drawdown() {
let equity = vec![100.0, 120.0, 90.0, 110.0, 85.0];
let max_dd = max_drawdown(&equity);
// Max DD should be (120-85)/120 = 29.17%
assert!((max_dd - 29.17).abs() < 0.1);
}
#[test]
fn test_drawdown_periods() {
let equity = vec![100.0, 110.0, 105.0, 115.0, 100.0, 120.0];
let periods = drawdown_periods(&equity);
// Should have 2 drawdown periods
assert_eq!(periods.len(), 2);
}
#[test]
fn test_calmar_ratio() {
// 50% return with 10% max drawdown
let calmar = calmar_ratio(50.0, 10.0);
assert!((calmar - 5.0).abs() < 1e-10);
}
#[test]
fn test_ulcer_index() {
let equity = vec![100.0, 95.0, 90.0, 95.0, 100.0];
let ui = ulcer_index(&equity);
// Should be positive (there were drawdowns)
assert!(ui > 0.0);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Performance metrics for RaptorBT.
pub mod drawdown;
pub mod streaming;
pub mod trade_stats;
pub use drawdown::DrawdownTracker;
pub use streaming::StreamingMetrics;
pub use trade_stats::TradeStatistics;
+756
View File
@@ -0,0 +1,756 @@
//! Streaming metrics calculation using Welford's algorithm.
//!
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
use crate::core::types::BacktestMetrics;
/// Streaming metrics calculator using Welford's algorithm.
///
/// Allows incremental calculation of statistics without storing all values.
/// Also tracks equity and drawdown for backtesting.
#[derive(Debug, Clone)]
pub struct StreamingMetrics {
/// Number of observations.
count: usize,
/// Running mean.
mean: f64,
/// Running M2 for variance calculation.
m2: f64,
/// Running M2 for downside variance (Sortino).
m2_downside: f64,
/// Target return for Sortino (default: 0).
target_return: f64,
/// Sum of returns (for total return calculation).
sum: f64,
/// Sum of positive returns.
sum_positive: f64,
/// Sum of negative returns.
sum_negative: f64,
/// Count of positive returns.
count_positive: usize,
/// Count of negative returns.
count_negative: usize,
// === Equity and drawdown tracking ===
/// Initial capital.
#[allow(dead_code)]
initial_capital: f64,
/// Peak equity value (for drawdown calculation).
peak_equity: f64,
/// Current equity value.
current_equity: f64,
/// Maximum drawdown percentage.
max_drawdown_pct: f64,
/// Current drawdown percentage.
current_drawdown: f64,
/// Bars since peak (for max drawdown duration).
bars_since_peak: usize,
/// Maximum drawdown duration in bars.
max_drawdown_duration: usize,
// === Trade tracking ===
/// Number of trades.
trade_count: usize,
/// Number of winning trades.
winning_trades: usize,
/// Number of losing trades.
losing_trades: usize,
/// Sum of winning trade P&L.
sum_wins: f64,
/// Sum of losing trade P&L.
sum_losses: f64,
/// Sum of trade return percentages.
sum_trade_returns: f64,
/// Sum of squared trade return percentages (for SQN).
sum_trade_returns_sq: f64,
/// Best trade return percentage.
best_trade_pct: f64,
/// Worst trade return percentage.
worst_trade_pct: f64,
/// Sum of winning trade durations.
sum_winning_duration: usize,
/// Sum of losing trade durations.
sum_losing_duration: usize,
/// Current consecutive wins.
current_consecutive_wins: usize,
/// Current consecutive losses.
current_consecutive_losses: usize,
/// Maximum consecutive wins.
max_consecutive_wins: usize,
/// Maximum consecutive losses.
max_consecutive_losses: usize,
/// Total holding period (bars).
total_holding_period: usize,
/// Total fees paid.
total_fees: f64,
}
impl Default for StreamingMetrics {
fn default() -> Self {
Self::new()
}
}
impl StreamingMetrics {
/// Create a new streaming metrics calculator.
pub fn new() -> Self {
Self::with_initial_capital(0.0)
}
/// Create a new streaming metrics calculator with initial capital.
pub fn with_initial_capital(initial_capital: f64) -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
m2_downside: 0.0,
target_return: 0.0,
sum: 0.0,
sum_positive: 0.0,
sum_negative: 0.0,
count_positive: 0,
count_negative: 0,
// Equity tracking
initial_capital,
peak_equity: initial_capital,
current_equity: initial_capital,
max_drawdown_pct: 0.0,
current_drawdown: 0.0,
bars_since_peak: 0,
max_drawdown_duration: 0,
// Trade tracking
trade_count: 0,
winning_trades: 0,
losing_trades: 0,
sum_wins: 0.0,
sum_losses: 0.0,
sum_trade_returns: 0.0,
sum_trade_returns_sq: 0.0,
best_trade_pct: f64::NEG_INFINITY,
worst_trade_pct: f64::INFINITY,
sum_winning_duration: 0,
sum_losing_duration: 0,
current_consecutive_wins: 0,
current_consecutive_losses: 0,
max_consecutive_wins: 0,
max_consecutive_losses: 0,
total_holding_period: 0,
total_fees: 0.0,
}
}
/// Create with a custom target return for Sortino calculation.
pub fn with_target_return(mut self, target: f64) -> Self {
self.target_return = target;
self
}
/// Update metrics with a new return value.
///
/// Uses Welford's online algorithm for numerically stable variance calculation.
pub fn update(&mut self, return_value: f64) {
self.count += 1;
self.sum += return_value;
// Track positive/negative
if return_value > 0.0 {
self.sum_positive += return_value;
self.count_positive += 1;
} else if return_value < 0.0 {
self.sum_negative += return_value;
self.count_negative += 1;
}
// Welford's algorithm for mean and variance
let delta = return_value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = return_value - self.mean;
self.m2 += delta * delta2;
// Downside variance (for Sortino)
let downside = (return_value - self.target_return).min(0.0);
let _delta_down = downside - (self.m2_downside / self.count.max(1) as f64).sqrt();
self.m2_downside += downside * downside;
}
/// Get the number of observations.
#[inline]
pub fn count(&self) -> usize {
self.count
}
/// Get the running mean.
#[inline]
pub fn mean(&self) -> f64 {
self.mean
}
/// Get the sample variance.
pub fn variance(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
self.m2 / (self.count - 1) as f64
}
/// Get the population variance.
pub fn variance_population(&self) -> f64 {
if self.count == 0 {
return 0.0;
}
self.m2 / self.count as f64
}
/// Get the sample standard deviation.
pub fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
/// Get the downside standard deviation (for Sortino).
pub fn downside_std_dev(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
(self.m2_downside / (self.count - 1) as f64).sqrt()
}
/// Calculate Sharpe ratio.
///
/// # Arguments
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
/// * `risk_free_rate` - Annual risk-free rate (default: 0)
///
/// # Returns
/// Annualized Sharpe ratio
pub fn sharpe_ratio(&self, periods_per_year: f64) -> f64 {
self.sharpe_ratio_with_rf(periods_per_year, 0.0)
}
/// Calculate Sharpe ratio with custom risk-free rate.
pub fn sharpe_ratio_with_rf(&self, periods_per_year: f64, risk_free_rate: f64) -> f64 {
let std = self.std_dev();
if std == 0.0 || self.count < 2 {
return 0.0;
}
let rf_per_period = risk_free_rate / periods_per_year;
let excess_return = self.mean - rf_per_period;
let annualized_excess = excess_return * periods_per_year;
let annualized_std = std * periods_per_year.sqrt();
annualized_excess / annualized_std
}
/// Calculate Sortino ratio.
///
/// # Arguments
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
///
/// # Returns
/// Annualized Sortino ratio
pub fn sortino_ratio(&self, periods_per_year: f64) -> f64 {
let downside_std = self.downside_std_dev();
if downside_std == 0.0 || self.count < 2 {
return if self.mean > 0.0 { f64::INFINITY } else { 0.0 };
}
let excess_return = self.mean - self.target_return;
let annualized_excess = excess_return * periods_per_year;
let annualized_downside_std = downside_std * periods_per_year.sqrt();
annualized_excess / annualized_downside_std
}
/// Get total return.
pub fn total_return(&self) -> f64 {
self.sum
}
/// Get average positive return.
pub fn avg_positive_return(&self) -> f64 {
if self.count_positive == 0 {
return 0.0;
}
self.sum_positive / self.count_positive as f64
}
/// Get average negative return.
pub fn avg_negative_return(&self) -> f64 {
if self.count_negative == 0 {
return 0.0;
}
self.sum_negative / self.count_negative as f64
}
/// Get win rate (fraction of positive returns).
pub fn win_rate(&self) -> f64 {
if self.count == 0 {
return 0.0;
}
self.count_positive as f64 / self.count as f64
}
/// Get profit factor (sum of profits / sum of losses).
pub fn profit_factor(&self) -> f64 {
if self.sum_negative == 0.0 {
return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 };
}
self.sum_positive / self.sum_negative.abs()
}
/// Get omega ratio (same as profit factor for return-based calculation).
/// Omega = (sum of returns above threshold) / |sum of returns below threshold|
/// With threshold = 0, this equals profit_factor.
pub fn omega_ratio(&self) -> f64 {
self.profit_factor()
}
// === Equity tracking methods ===
/// Update equity and calculate drawdown.
pub fn update_equity(&mut self, equity: f64) {
self.current_equity = equity;
if equity > self.peak_equity {
self.peak_equity = equity;
self.bars_since_peak = 0;
} else {
self.bars_since_peak += 1;
if self.bars_since_peak > self.max_drawdown_duration {
self.max_drawdown_duration = self.bars_since_peak;
}
}
// Calculate current drawdown percentage
if self.peak_equity > 0.0 {
self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0;
if self.current_drawdown > self.max_drawdown_pct {
self.max_drawdown_pct = self.current_drawdown;
}
}
}
/// Get current drawdown percentage.
#[inline]
pub fn current_drawdown_pct(&self) -> f64 {
self.current_drawdown
}
/// Get maximum drawdown percentage.
#[inline]
pub fn max_drawdown_pct(&self) -> f64 {
self.max_drawdown_pct
}
// === Trade tracking methods ===
/// Record a completed trade.
///
/// # Arguments
/// * `pnl` - Trade profit/loss
/// * `return_pct` - Trade return percentage
/// * `duration` - Trade duration in bars
pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) {
self.trade_count += 1;
self.sum_trade_returns += return_pct;
self.sum_trade_returns_sq += return_pct * return_pct;
self.total_holding_period += duration;
// Track best/worst trades
if return_pct > self.best_trade_pct {
self.best_trade_pct = return_pct;
}
if return_pct < self.worst_trade_pct {
self.worst_trade_pct = return_pct;
}
if pnl > 0.0 {
self.winning_trades += 1;
self.sum_wins += pnl;
self.sum_winning_duration += duration;
self.current_consecutive_wins += 1;
self.current_consecutive_losses = 0;
if self.current_consecutive_wins > self.max_consecutive_wins {
self.max_consecutive_wins = self.current_consecutive_wins;
}
} else if pnl < 0.0 {
self.losing_trades += 1;
self.sum_losses += pnl.abs();
self.sum_losing_duration += duration;
self.current_consecutive_losses += 1;
self.current_consecutive_wins = 0;
if self.current_consecutive_losses > self.max_consecutive_losses {
self.max_consecutive_losses = self.current_consecutive_losses;
}
}
}
/// Record fees paid.
pub fn record_fees(&mut self, fees: f64) {
self.total_fees += fees;
}
/// Finalize metrics and produce BacktestMetrics.
///
/// # Arguments
/// * `initial_capital` - Starting capital
/// * `final_value` - Ending portfolio value
/// * `returns` - Array of period returns for ratio calculations
pub fn finalize(
&self,
initial_capital: f64,
final_value: f64,
returns: &[f64],
) -> BacktestMetrics {
// Calculate return metrics from the returns array
let mut return_metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
return_metrics.update(r);
}
}
let total_return_pct = if initial_capital > 0.0 {
(final_value - initial_capital) / initial_capital * 100.0
} else {
0.0
};
// Calculate trade-based metrics
let win_rate_pct = if self.trade_count > 0 {
self.winning_trades as f64 / self.trade_count as f64 * 100.0
} else {
0.0
};
let profit_factor = if self.sum_losses > 0.0 {
self.sum_wins / self.sum_losses
} else if self.sum_wins > 0.0 {
f64::INFINITY
} else {
0.0
};
let avg_trade_return_pct = if self.trade_count > 0 {
self.sum_trade_returns / self.trade_count as f64
} else {
0.0
};
let avg_win_pct = if self.winning_trades > 0 {
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
} else {
0.0
};
let avg_loss_pct = if self.losing_trades > 0 {
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
} else {
0.0
};
let avg_winning_duration = if self.winning_trades > 0 {
self.sum_winning_duration as f64 / self.winning_trades as f64
} else {
0.0
};
let avg_losing_duration = if self.losing_trades > 0 {
self.sum_losing_duration as f64 / self.losing_trades as f64
} else {
0.0
};
let avg_holding_period = if self.trade_count > 0 {
self.total_holding_period as f64 / self.trade_count as f64
} else {
0.0
};
// Expectancy: average profit per trade
let expectancy = if self.trade_count > 0 {
(self.sum_wins - self.sum_losses) / self.trade_count as f64
} else {
0.0
};
// SQN (System Quality Number)
let sqn = if self.trade_count > 1 {
let mean_return = self.sum_trade_returns / self.trade_count as f64;
let variance =
(self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return);
let std_dev = variance.max(0.0).sqrt();
if std_dev > 0.0 {
(mean_return / std_dev) * (self.trade_count as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Sharpe ratio (annualized, assuming 252 trading days)
let sharpe_ratio = return_metrics.sharpe_ratio(252.0);
// Sortino ratio (annualized)
let sortino_ratio = return_metrics.sortino_ratio(252.0);
// Calmar ratio (annualized return / max drawdown)
let calmar_ratio = if self.max_drawdown_pct > 0.0 {
total_return_pct / self.max_drawdown_pct
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Omega ratio
let omega_ratio = return_metrics.omega_ratio();
// Best/worst trade handling (handle edge cases)
let best_trade_pct =
if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct };
let worst_trade_pct =
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Recovery factor: net profit / max drawdown (absolute value)
let net_profit = final_value - initial_capital;
let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 {
let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital;
if max_dd_absolute > 0.0 {
net_profit / max_dd_absolute
} else {
0.0
}
} else if net_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
omega_ratio,
max_drawdown_pct: self.max_drawdown_pct,
max_drawdown_duration: self.max_drawdown_duration,
win_rate_pct,
profit_factor,
expectancy,
sqn,
total_trades: self.trade_count,
total_closed_trades: self.trade_count,
total_open_trades: 0,
open_trade_pnl: 0.0,
winning_trades: self.winning_trades,
losing_trades: self.losing_trades,
start_value: initial_capital,
end_value: final_value,
total_fees_paid: self.total_fees,
best_trade_pct,
worst_trade_pct,
avg_trade_return_pct,
avg_win_pct,
avg_loss_pct,
avg_winning_duration,
avg_losing_duration,
max_consecutive_wins: self.max_consecutive_wins,
max_consecutive_losses: self.max_consecutive_losses,
avg_holding_period,
exposure_pct: 0.0, // TODO: calculate based on time in market
payoff_ratio,
recovery_factor,
}
}
/// Reset all metrics.
pub fn reset(&mut self) {
*self = Self::new();
}
/// Merge two streaming metrics (for parallel computation).
pub fn merge(&mut self, other: &StreamingMetrics) {
if other.count == 0 {
return;
}
if self.count == 0 {
*self = other.clone();
return;
}
let combined_count = self.count + other.count;
let delta = other.mean - self.mean;
// Merge means
let combined_mean = self.mean + delta * other.count as f64 / combined_count as f64;
// Merge M2 (parallel variance)
let combined_m2 = self.m2
+ other.m2
+ delta * delta * self.count as f64 * other.count as f64 / combined_count as f64;
// Update state
self.count = combined_count;
self.mean = combined_mean;
self.m2 = combined_m2;
self.sum += other.sum;
self.sum_positive += other.sum_positive;
self.sum_negative += other.sum_negative;
self.count_positive += other.count_positive;
self.count_negative += other.count_negative;
self.m2_downside += other.m2_downside; // Approximation
}
}
/// Calculate Sharpe ratio from a slice of returns.
pub fn sharpe_ratio(returns: &[f64], periods_per_year: f64, risk_free_rate: f64) -> f64 {
let mut metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
metrics.update(r);
}
}
metrics.sharpe_ratio_with_rf(periods_per_year, risk_free_rate)
}
/// Calculate Sortino ratio from a slice of returns.
pub fn sortino_ratio(returns: &[f64], periods_per_year: f64) -> f64 {
let mut metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
metrics.update(r);
}
}
metrics.sortino_ratio(periods_per_year)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_statistics() {
let mut metrics = StreamingMetrics::new();
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
for v in &values {
metrics.update(*v);
}
assert_eq!(metrics.count(), 5);
assert!((metrics.mean() - 3.0).abs() < 1e-10);
// Sample variance of [1,2,3,4,5] = 2.5
assert!((metrics.variance() - 2.5).abs() < 1e-10);
}
#[test]
fn test_welford_numerical_stability() {
let mut metrics = StreamingMetrics::new();
// Large values that might cause numerical issues with naive algorithm
let base = 1e10;
let values = vec![base + 1.0, base + 2.0, base + 3.0];
for v in &values {
metrics.update(*v);
}
// Mean should be base + 2
assert!((metrics.mean() - (base + 2.0)).abs() < 1e-5);
// Variance should be 1.0 (same as [1, 2, 3])
assert!((metrics.variance() - 1.0).abs() < 1e-5);
}
#[test]
fn test_sharpe_ratio() {
let mut metrics = StreamingMetrics::new();
// Daily returns: 1%, 2%, -1%, 1.5%, 0.5%
let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005];
for r in &returns {
metrics.update(*r);
}
// Should produce a positive Sharpe ratio
let sharpe = metrics.sharpe_ratio(252.0);
assert!(sharpe > 0.0);
}
#[test]
fn test_sortino_ratio() {
let mut metrics = StreamingMetrics::new();
// Mix of positive and negative returns
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
for r in &returns {
metrics.update(*r);
}
// Sortino should be different from Sharpe
let sharpe = metrics.sharpe_ratio(252.0);
let sortino = metrics.sortino_ratio(252.0);
// With negative returns, Sortino penalizes only downside
assert!(sortino != sharpe);
}
#[test]
fn test_win_rate_and_profit_factor() {
let mut metrics = StreamingMetrics::new();
// 3 wins, 2 losses
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
for r in &returns {
metrics.update(*r);
}
// Win rate should be 60%
assert!((metrics.win_rate() - 0.6).abs() < 1e-10);
// Profit factor = 0.06 / 0.03 = 2.0
assert!((metrics.profit_factor() - 2.0).abs() < 1e-10);
}
#[test]
fn test_merge() {
let mut m1 = StreamingMetrics::new();
let mut m2 = StreamingMetrics::new();
// Split data between two calculators
for v in &[1.0, 2.0, 3.0] {
m1.update(*v);
}
for v in &[4.0, 5.0] {
m2.update(*v);
}
// Merge
m1.merge(&m2);
// Should match single calculator with all data
let mut combined = StreamingMetrics::new();
for v in &[1.0, 2.0, 3.0, 4.0, 5.0] {
combined.update(*v);
}
assert_eq!(m1.count(), combined.count());
assert!((m1.mean() - combined.mean()).abs() < 1e-10);
assert!((m1.variance() - combined.variance()).abs() < 1e-10);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! Trade statistics calculation.
use crate::core::types::Trade;
/// Comprehensive trade statistics.
#[derive(Debug, Clone, Default)]
pub struct TradeStatistics {
/// Total number of trades.
pub total_trades: usize,
/// Number of winning trades.
pub winning_trades: usize,
/// Number of losing trades.
pub losing_trades: usize,
/// Number of breakeven trades.
pub breakeven_trades: usize,
/// Win rate (as percentage).
pub win_rate: f64,
/// Average win amount.
pub avg_win: f64,
/// Average loss amount.
pub avg_loss: f64,
/// Largest win.
pub largest_win: f64,
/// Largest loss.
pub largest_loss: f64,
/// Total profit.
pub total_profit: f64,
/// Total loss.
pub total_loss: f64,
/// Net profit.
pub net_profit: f64,
/// Profit factor.
pub profit_factor: f64,
/// Expected value per trade.
pub expectancy: f64,
/// Average trade return percentage.
pub avg_return_pct: f64,
/// Average holding period (bars).
pub avg_holding_period: f64,
/// Max consecutive wins.
pub max_consecutive_wins: usize,
/// Max consecutive losses.
pub max_consecutive_losses: usize,
/// Average win/loss ratio.
pub avg_win_loss_ratio: f64,
/// Recovery factor (net profit / max loss).
pub recovery_factor: f64,
/// Payoff ratio (avg win / avg loss).
pub payoff_ratio: f64,
}
impl TradeStatistics {
/// Calculate statistics from a list of trades.
pub fn from_trades(trades: &[Trade]) -> Self {
let mut stats = Self::default();
if trades.is_empty() {
return stats;
}
stats.total_trades = trades.len();
// Categorize trades
for trade in trades {
if trade.pnl > 0.0 {
stats.winning_trades += 1;
stats.total_profit += trade.pnl;
if trade.pnl > stats.largest_win {
stats.largest_win = trade.pnl;
}
} else if trade.pnl < 0.0 {
stats.losing_trades += 1;
stats.total_loss += trade.pnl.abs();
if trade.pnl.abs() > stats.largest_loss {
stats.largest_loss = trade.pnl.abs();
}
} else {
stats.breakeven_trades += 1;
}
}
// Calculate ratios
stats.net_profit = stats.total_profit - stats.total_loss;
if stats.total_trades > 0 {
stats.win_rate = stats.winning_trades as f64 / stats.total_trades as f64 * 100.0;
}
if stats.winning_trades > 0 {
stats.avg_win = stats.total_profit / stats.winning_trades as f64;
}
if stats.losing_trades > 0 {
stats.avg_loss = stats.total_loss / stats.losing_trades as f64;
}
if stats.total_loss > 0.0 {
stats.profit_factor = stats.total_profit / stats.total_loss;
} else if stats.total_profit > 0.0 {
stats.profit_factor = f64::INFINITY;
}
if stats.avg_loss > 0.0 {
stats.payoff_ratio = stats.avg_win / stats.avg_loss;
}
// Expectancy
if stats.total_trades > 0 {
stats.expectancy = stats.net_profit / stats.total_trades as f64;
}
// Average return percentage
if stats.total_trades > 0 {
stats.avg_return_pct =
trades.iter().map(|t| t.return_pct).sum::<f64>() / stats.total_trades as f64;
}
// Average holding period
if stats.total_trades > 0 {
stats.avg_holding_period =
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>()
/ stats.total_trades as f64;
}
// Consecutive wins/losses
let (max_wins, max_losses) = calculate_consecutive(trades);
stats.max_consecutive_wins = max_wins;
stats.max_consecutive_losses = max_losses;
// Recovery factor
if stats.largest_loss > 0.0 {
stats.recovery_factor = stats.net_profit / stats.largest_loss;
}
// Win/loss ratio
if stats.losing_trades > 0 {
stats.avg_win_loss_ratio = stats.winning_trades as f64 / stats.losing_trades as f64;
}
stats
}
/// Get summary as formatted string.
pub fn summary(&self) -> String {
format!(
"Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2} | Net: {:.2}",
self.total_trades, self.win_rate, self.profit_factor, self.net_profit
)
}
/// Check if strategy is profitable.
pub fn is_profitable(&self) -> bool {
self.net_profit > 0.0
}
/// Get edge (expected value as percentage of average trade).
pub fn edge(&self) -> f64 {
if self.total_trades == 0 {
return 0.0;
}
let avg_trade = self.net_profit / self.total_trades as f64;
let avg_cost = (self.total_profit + self.total_loss) / self.total_trades as f64;
if avg_cost > 0.0 {
avg_trade / avg_cost * 100.0
} else {
0.0
}
}
}
/// Calculate maximum consecutive wins and losses.
fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
let mut max_wins = 0;
let mut max_losses = 0;
let mut current_wins = 0;
let mut current_losses = 0;
for trade in trades {
if trade.pnl > 0.0 {
current_wins += 1;
current_losses = 0;
max_wins = max_wins.max(current_wins);
} else if trade.pnl < 0.0 {
current_losses += 1;
current_wins = 0;
max_losses = max_losses.max(current_losses);
}
}
(max_wins, max_losses)
}
/// Monthly returns breakdown.
#[derive(Debug, Clone, Default)]
pub struct MonthlyReturns {
/// Year.
pub year: i32,
/// Month (1-12).
pub month: u8,
/// Return percentage.
pub return_pct: f64,
/// Number of trades.
pub trade_count: usize,
}
/// Calculate trade statistics by exit reason.
pub fn stats_by_exit_reason(
trades: &[Trade],
) -> std::collections::HashMap<crate::core::types::ExitReason, TradeStatistics> {
use crate::core::types::ExitReason;
use std::collections::HashMap;
let mut grouped: HashMap<ExitReason, Vec<&Trade>> = HashMap::new();
for trade in trades {
grouped.entry(trade.exit_reason).or_default().push(trade);
}
grouped
.into_iter()
.map(|(reason, trade_refs)| {
let owned_trades: Vec<Trade> = trade_refs.into_iter().cloned().collect();
(reason, TradeStatistics::from_trades(&owned_trades))
})
.collect()
}
/// Calculate statistics for long vs short trades.
pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) {
use crate::core::types::Direction;
let long_trades: Vec<Trade> =
trades.iter().filter(|t| t.direction == Direction::Long).cloned().collect();
let short_trades: Vec<Trade> =
trades.iter().filter(|t| t.direction == Direction::Short).cloned().collect();
(TradeStatistics::from_trades(&long_trades), TradeStatistics::from_trades(&short_trades))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{Direction, ExitReason};
fn sample_trades() -> Vec<Trade> {
vec![
Trade {
id: 1,
symbol: "TEST".to_string(),
entry_idx: 0,
exit_idx: 5,
entry_price: 100.0,
exit_price: 110.0,
size: 10.0,
direction: Direction::Long,
pnl: 100.0, // Win
return_pct: 10.0,
entry_time: 0,
exit_time: 5,
fees: 0.0,
exit_reason: ExitReason::Signal,
},
Trade {
id: 2,
symbol: "TEST".to_string(),
entry_idx: 10,
exit_idx: 15,
entry_price: 100.0,
exit_price: 95.0,
size: 10.0,
direction: Direction::Long,
pnl: -50.0, // Loss
return_pct: -5.0,
entry_time: 10,
exit_time: 15,
fees: 0.0,
exit_reason: ExitReason::StopLoss,
},
Trade {
id: 3,
symbol: "TEST".to_string(),
entry_idx: 20,
exit_idx: 25,
entry_price: 100.0,
exit_price: 108.0,
size: 10.0,
direction: Direction::Long,
pnl: 80.0, // Win
return_pct: 8.0,
entry_time: 20,
exit_time: 25,
fees: 0.0,
exit_reason: ExitReason::TakeProfit,
},
]
}
#[test]
fn test_basic_stats() {
let trades = sample_trades();
let stats = TradeStatistics::from_trades(&trades);
assert_eq!(stats.total_trades, 3);
assert_eq!(stats.winning_trades, 2);
assert_eq!(stats.losing_trades, 1);
assert!((stats.win_rate - 66.67).abs() < 0.1);
}
#[test]
fn test_profit_calculations() {
let trades = sample_trades();
let stats = TradeStatistics::from_trades(&trades);
assert!((stats.total_profit - 180.0).abs() < 1e-10);
assert!((stats.total_loss - 50.0).abs() < 1e-10);
assert!((stats.net_profit - 130.0).abs() < 1e-10);
assert!((stats.profit_factor - 3.6).abs() < 0.1);
}
#[test]
fn test_consecutive() {
let trades = sample_trades();
let (max_wins, max_losses) = calculate_consecutive(&trades);
// W, L, W -> max consecutive wins = 1, max consecutive losses = 1
assert_eq!(max_wins, 1);
assert_eq!(max_losses, 1);
}
#[test]
fn test_stats_by_exit_reason() {
let trades = sample_trades();
let by_reason = stats_by_exit_reason(&trades);
// Should have 3 different exit reasons
assert!(by_reason.contains_key(&ExitReason::Signal));
assert!(by_reason.contains_key(&ExitReason::StopLoss));
assert!(by_reason.contains_key(&ExitReason::TakeProfit));
}
#[test]
fn test_empty_trades() {
let stats = TradeStatistics::from_trades(&[]);
assert_eq!(stats.total_trades, 0);
assert!((stats.win_rate - 0.0).abs() < 1e-10);
assert!((stats.profit_factor - 0.0).abs() < 1e-10);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Capital allocation strategies for portfolio management.
/// Allocation strategy for distributing capital across instruments.
#[derive(Debug, Clone)]
pub enum AllocationStrategy {
/// Equal weight across all instruments.
EqualWeight,
/// Fixed weight for each instrument.
FixedWeight(Vec<f64>),
/// Volatility-based weighting (inverse volatility).
InverseVolatility,
/// Risk parity (equal risk contribution).
RiskParity,
/// Maximum weight per instrument.
MaxWeight(f64),
/// Custom weights.
Custom(Vec<(String, f64)>),
}
impl Default for AllocationStrategy {
fn default() -> Self {
AllocationStrategy::EqualWeight
}
}
/// Capital allocator for managing position sizing and capital distribution.
#[derive(Debug, Clone)]
pub struct CapitalAllocator {
/// Total capital.
pub total_capital: f64,
/// Available capital (not in positions).
pub available_capital: f64,
/// Allocation strategy.
pub strategy: AllocationStrategy,
/// Maximum position size as fraction of capital.
pub max_position_size: f64,
/// Minimum position size (absolute).
pub min_position_size: f64,
/// Reserve capital fraction (never allocate).
pub reserve_fraction: f64,
}
impl CapitalAllocator {
/// Create a new capital allocator.
pub fn new(total_capital: f64) -> Self {
Self {
total_capital,
available_capital: total_capital,
strategy: AllocationStrategy::EqualWeight,
max_position_size: 1.0,
min_position_size: 0.0,
reserve_fraction: 0.0,
}
}
/// Set allocation strategy.
pub fn with_strategy(mut self, strategy: AllocationStrategy) -> Self {
self.strategy = strategy;
self
}
/// Set maximum position size.
pub fn with_max_position(mut self, max_fraction: f64) -> Self {
self.max_position_size = max_fraction.clamp(0.0, 1.0);
self
}
/// Set reserve fraction.
pub fn with_reserve(mut self, reserve: f64) -> Self {
self.reserve_fraction = reserve.clamp(0.0, 1.0);
self
}
/// Calculate position size for a single instrument.
///
/// # Arguments
/// * `price` - Entry price
/// * `num_instruments` - Total number of instruments in portfolio
/// * `instrument_weight` - Optional custom weight for this instrument
///
/// # Returns
/// Position size in shares/contracts
pub fn calculate_position_size(
&self,
price: f64,
num_instruments: usize,
instrument_weight: Option<f64>,
) -> f64 {
if price <= 0.0 || num_instruments == 0 {
return 0.0;
}
// Calculate allocatable capital
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
// Calculate weight
let weight = match &self.strategy {
AllocationStrategy::EqualWeight => 1.0 / num_instruments as f64,
AllocationStrategy::FixedWeight(weights) => {
if weights.is_empty() {
1.0 / num_instruments as f64
} else {
weights[0].min(self.max_position_size)
}
}
AllocationStrategy::MaxWeight(max) => (*max).min(1.0 / num_instruments as f64),
_ => instrument_weight.unwrap_or(1.0 / num_instruments as f64),
};
// Calculate allocation
let allocation = allocatable * weight.min(self.max_position_size);
// Convert to shares
let shares = allocation / price;
// Apply minimum size constraint
if shares * price < self.min_position_size {
return 0.0;
}
shares
}
/// Calculate position sizes for multiple instruments.
///
/// # Arguments
/// * `prices` - Entry prices for each instrument
/// * `weights` - Optional weights for each instrument
///
/// # Returns
/// Position sizes for each instrument
pub fn calculate_portfolio_sizes(&self, prices: &[f64], weights: Option<&[f64]>) -> Vec<f64> {
let n = prices.len();
if n == 0 {
return vec![];
}
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
// Get weights
let instrument_weights: Vec<f64> = match &self.strategy {
AllocationStrategy::EqualWeight => vec![1.0 / n as f64; n],
AllocationStrategy::FixedWeight(w) => {
if w.len() == n {
w.clone()
} else {
vec![1.0 / n as f64; n]
}
}
AllocationStrategy::MaxWeight(max) => {
let equal = 1.0 / n as f64;
vec![equal.min(*max); n]
}
_ => weights.map(|w| w.to_vec()).unwrap_or_else(|| vec![1.0 / n as f64; n]),
};
// Normalize weights
let total_weight: f64 = instrument_weights.iter().sum();
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
instrument_weights.iter().map(|w| w / total_weight).collect()
} else {
vec![1.0 / n as f64; n]
};
// Calculate sizes
prices
.iter()
.zip(normalized_weights.iter())
.map(|(&price, &weight)| {
if price <= 0.0 {
return 0.0;
}
let allocation = allocatable * weight.min(self.max_position_size);
let shares = allocation / price;
if shares * price < self.min_position_size {
0.0
} else {
shares
}
})
.collect()
}
/// Calculate volatility-adjusted position size.
///
/// # Arguments
/// * `price` - Entry price
/// * `volatility` - Instrument volatility (e.g., ATR)
/// * `risk_per_trade` - Risk per trade as fraction of capital
///
/// # Returns
/// Position size
pub fn calculate_volatility_sized(
&self,
price: f64,
volatility: f64,
risk_per_trade: f64,
) -> f64 {
if price <= 0.0 || volatility <= 0.0 {
return 0.0;
}
let risk_amount = self.available_capital * risk_per_trade;
let size = risk_amount / volatility;
// Apply maximum constraint
let max_allocation = self.available_capital * self.max_position_size;
let max_shares = max_allocation / price;
size.min(max_shares)
}
/// Allocate capital to a position.
///
/// # Arguments
/// * `amount` - Amount to allocate
///
/// # Returns
/// True if allocation succeeded
pub fn allocate(&mut self, amount: f64) -> bool {
if amount > self.available_capital {
return false;
}
self.available_capital -= amount;
true
}
/// Release capital from a closed position.
///
/// # Arguments
/// * `amount` - Amount to release (including P&L)
pub fn release(&mut self, amount: f64) {
self.available_capital += amount;
}
/// Update total capital (e.g., after deposit/withdrawal or daily mark-to-market).
pub fn update_capital(&mut self, new_capital: f64) {
let diff = new_capital - self.total_capital;
self.total_capital = new_capital;
self.available_capital += diff;
}
/// Get current utilization rate.
pub fn utilization(&self) -> f64 {
if self.total_capital <= 0.0 {
return 0.0;
}
1.0 - (self.available_capital / self.total_capital)
}
/// Reset allocator to initial state.
pub fn reset(&mut self) {
self.available_capital = self.total_capital;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_equal_weight() {
let allocator = CapitalAllocator::new(100_000.0);
// 4 instruments, equal weight = 25% each
let size = allocator.calculate_position_size(100.0, 4, None);
// Expected: 100000 * 0.25 / 100 = 250 shares
assert!((size - 250.0).abs() < 1e-10);
}
#[test]
fn test_max_position() {
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.1);
// Even with 1 instrument, max is 10%
let size = allocator.calculate_position_size(100.0, 1, None);
// Expected: 100000 * 0.1 / 100 = 100 shares
assert!((size - 100.0).abs() < 1e-10);
}
#[test]
fn test_portfolio_sizes() {
let allocator = CapitalAllocator::new(100_000.0);
let prices = vec![100.0, 50.0, 200.0];
let sizes = allocator.calculate_portfolio_sizes(&prices, None);
assert_eq!(sizes.len(), 3);
// Equal weight, each gets 1/3 of capital
// Instrument 1: 33333 / 100 = 333.33
// Instrument 2: 33333 / 50 = 666.66
// Instrument 3: 33333 / 200 = 166.66
assert!((sizes[0] - 333.33).abs() < 1.0);
assert!((sizes[1] - 666.66).abs() < 1.0);
assert!((sizes[2] - 166.66).abs() < 1.0);
}
#[test]
fn test_allocate_release() {
let mut allocator = CapitalAllocator::new(100_000.0);
// Allocate 30000
assert!(allocator.allocate(30_000.0));
assert!((allocator.available_capital - 70_000.0).abs() < 1e-10);
// Try to allocate more than available
assert!(!allocator.allocate(80_000.0));
// Release with profit
allocator.release(35_000.0);
assert!((allocator.available_capital - 105_000.0).abs() < 1e-10);
}
#[test]
fn test_utilization() {
let mut allocator = CapitalAllocator::new(100_000.0);
assert!((allocator.utilization() - 0.0).abs() < 1e-10);
allocator.allocate(50_000.0);
assert!((allocator.utilization() - 0.5).abs() < 1e-10);
}
#[test]
fn test_volatility_sizing() {
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.2);
// Risk 1% per trade with ATR of 2
let size = allocator.calculate_volatility_sized(100.0, 2.0, 0.01);
// Risk amount: 100000 * 0.01 = 1000
// Size: 1000 / 2 = 500 shares
// Max: 100000 * 0.2 / 100 = 200 shares
// Should be capped at max
assert!((size - 200.0).abs() < 1e-10);
}
}
+902
View File
@@ -0,0 +1,902 @@
//! Event-driven portfolio simulation engine.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
InstrumentConfig, OhlcvData, Price, StopConfig, TargetConfig, Trade,
};
use crate::execution::{FeeModel, FillPrice, SlippageModel};
use crate::indicators::volatility::atr;
use crate::metrics::streaming::StreamingMetrics;
use crate::portfolio::position::PositionManager;
use crate::signals::processor::SignalProcessor;
/// Portfolio simulation engine.
///
/// Single-pass O(n) algorithm for simulating portfolio performance.
#[derive(Debug)]
pub struct PortfolioEngine {
/// Configuration.
pub config: BacktestConfig,
/// Fee model.
pub fee_model: FeeModel,
/// Slippage model.
pub slippage_model: SlippageModel,
/// Fill price model.
pub fill_price: FillPrice,
/// Signal processor.
pub signal_processor: SignalProcessor,
}
impl Default for PortfolioEngine {
fn default() -> Self {
Self::new(BacktestConfig::default())
}
}
impl PortfolioEngine {
/// Create a new portfolio engine with the given configuration.
pub fn new(config: BacktestConfig) -> Self {
let fee_model = FeeModel::percentage(config.fees);
let fill_price = if config.upon_bar_close { FillPrice::Close } else { FillPrice::Open };
Self {
config,
fee_model,
slippage_model: SlippageModel::None,
fill_price,
signal_processor: SignalProcessor::new(),
}
}
/// Set fee model.
pub fn with_fee_model(mut self, fee_model: FeeModel) -> Self {
self.fee_model = fee_model;
self
}
/// Set slippage model.
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model;
self
}
/// Run backtest on single instrument.
///
/// # Arguments
/// * `ohlcv` - OHLCV data
/// * `signals` - Compiled trading signals
///
/// # Returns
/// Backtest result
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
self.run_single_with_instrument_config(ohlcv, signals, None)
}
/// Run backtest on single instrument with optional per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result
pub fn run_single_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
let n = ohlcv.len();
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
// Clean signals
let (entries, exits) =
self.signal_processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut position = PositionManager::new(signals.symbol.clone());
let mut cash = self.config.initial_capital;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
// Determine effective stop/target configs (per-instrument overrides take precedence)
let effective_stop =
inst_config.and_then(|ic| ic.stop.as_ref()).unwrap_or(&self.config.stop);
let effective_target =
inst_config.and_then(|ic| ic.target.as_ref()).unwrap_or(&self.config.target);
// Pre-calculate ATR for ATR-based stops
let atr_values = if matches!(effective_stop, StopConfig::Atr { .. })
|| matches!(effective_target, TargetConfig::Atr { .. })
{
let period = match effective_stop {
StopConfig::Atr { period, .. } => *period,
_ => match effective_target {
TargetConfig::Atr { period, .. } => *period,
_ => 14,
},
};
atr(&ohlcv.high, &ohlcv.low, &ohlcv.close, period).unwrap_or_else(|_| vec![0.0; n])
} else {
vec![0.0; n]
};
// Main simulation loop
for i in 0..n {
let close = ohlcv.close[i];
let high = ohlcv.high[i];
let low = ohlcv.low[i];
let timestamp = ohlcv.timestamps[i];
// Update position price tracking
position.update_price(high, low);
// Check for exits first (stops and signals)
if position.is_in_position() {
let mut exit_reason: Option<ExitReason> = None;
let mut exit_price = close;
// Check stop-loss
if position.is_stop_hit(low, high) {
exit_reason = Some(ExitReason::StopLoss);
exit_price = position.position.stop_price.unwrap();
// Adjust for gap through stop
match position.position.direction {
Direction::Long => {
if ohlcv.open[i] < exit_price {
exit_price = ohlcv.open[i];
}
}
Direction::Short => {
if ohlcv.open[i] > exit_price {
exit_price = ohlcv.open[i];
}
}
}
}
// Check take-profit
if exit_reason.is_none() && position.is_target_hit(low, high) {
exit_reason = Some(ExitReason::TakeProfit);
exit_price = position.position.target_price.unwrap();
}
// Check exit signal
if exit_reason.is_none() && exits[i] {
exit_reason = Some(ExitReason::Signal);
exit_price = self.get_fill_price(ohlcv, i, signals.direction, false);
}
// Execute exit
if let Some(reason) = exit_reason {
// Apply slippage
exit_price = self.slippage_model.apply(
exit_price,
position.position.direction,
false,
Some(ohlcv.volume[i]),
);
// Calculate fees
let fees = self.fee_model.calculate(
exit_price,
position.position.size,
position.position.direction,
);
// Close position
if let Some(trade) = position.close_position(
i,
timestamp,
exit_price,
ohlcv.timestamps[position.position.entry_idx],
reason,
fees,
) {
// Update cash
let exit_value = exit_price * trade.size;
cash += exit_value - fees;
// Track return for this trade
streaming.update(trade.return_pct / 100.0);
trades.push(trade);
}
}
// Update trailing stop if position still open
if position.is_in_position() {
if let StopConfig::Trailing { percent } = effective_stop {
position.update_trailing_stop(*percent);
}
}
}
// Check for entries
if !position.is_in_position() && entries[i] {
let entry_price = self.get_fill_price(ohlcv, i, signals.direction, true);
// Apply slippage
let adjusted_price = self.slippage_model.apply(
entry_price,
signals.direction,
true,
Some(ohlcv.volume[i]),
);
// Calculate position size
// Use per-instrument capital if set, capped at available cash
let available = inst_config
.and_then(|ic| ic.alloted_capital)
.map(|cap| cap.min(cash))
.unwrap_or(cash);
// Position sizing: size = cash / (price * (1 + fees))
// Ensures position value plus entry fee equals available cash
let fee_rate = self.config.fees;
let raw_size = if let Some(ref sizes) = signals.position_sizes {
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
} else {
available / (adjusted_price * (1.0 + fee_rate))
};
// Round to lot_size
let size = inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size);
if size > 0.0 {
// Calculate entry fees
let entry_fees =
self.fee_model.calculate(adjusted_price, size, signals.direction);
// Calculate stop and target prices
let (stop_price, target_price) = self.calculate_stop_target_with_config(
adjusted_price,
signals.direction,
&atr_values,
i,
effective_stop,
effective_target,
);
// Open position (passing entry_fees for trade PnL tracking)
position.open_position(
i,
timestamp,
adjusted_price,
size,
signals.direction,
stop_price,
target_price,
entry_fees,
);
// Deduct cost
cash -= adjusted_price * size + entry_fees;
}
}
// Calculate equity
let position_value =
if position.is_in_position() { close * position.position.size } else { 0.0 };
let equity = cash + position_value;
equity_curve[i] = equity;
// Calculate drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Mark any open position at end of data — marked-to-market, no exit fees
if position.is_in_position() {
let last_idx = n - 1;
let exit_price = ohlcv.close[last_idx];
// No exit fees for EndOfData: position is marked-to-market but not actually closed
let exit_fees = 0.0;
if let Some(trade) = position.close_position(
last_idx,
ohlcv.timestamps[last_idx],
exit_price,
ohlcv.timestamps[position.position.entry_idx],
ExitReason::EndOfData,
exit_fees,
) {
streaming.update(trade.return_pct / 100.0);
trades.push(trade);
}
}
// Calculate final metrics
let metrics =
self.calculate_metrics(&equity_curve, &drawdown_curve, &returns, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Get fill price based on model.
fn get_fill_price(
&self,
ohlcv: &OhlcvData,
idx: usize,
direction: Direction,
is_entry: bool,
) -> Price {
self.fill_price.get_price_from_arrays(
ohlcv.open[idx],
ohlcv.high[idx],
ohlcv.low[idx],
ohlcv.close[idx],
direction,
is_entry,
)
}
/// Calculate stop and target prices using the global config.
#[allow(dead_code)]
fn calculate_stop_target(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
) -> (Option<Price>, Option<Price>) {
self.calculate_stop_target_with_config(
entry_price,
direction,
atr_values,
idx,
&self.config.stop,
&self.config.target,
)
}
/// Calculate stop and target prices with explicit stop/target configs.
fn calculate_stop_target_with_config(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
stop_config: &StopConfig,
target_config: &TargetConfig,
) -> (Option<Price>, Option<Price>) {
let multiplier = direction.multiplier();
// Calculate stop price
let stop_price = match stop_config {
StopConfig::None => None,
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
StopConfig::Atr { multiplier: m, .. } => {
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
if atr > 0.0 {
Some(entry_price - multiplier * m * atr)
} else {
None
}
}
StopConfig::Trailing { percent } => Some(entry_price * (1.0 - multiplier * percent)),
};
// Calculate target price
let target_price = match target_config {
TargetConfig::None => None,
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
TargetConfig::Atr { multiplier: m, .. } => {
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
if atr > 0.0 {
Some(entry_price + multiplier * m * atr)
} else {
None
}
}
TargetConfig::RiskReward { ratio } => {
if let Some(stop) = stop_price {
let risk = (entry_price - stop).abs();
Some(entry_price + multiplier * risk * ratio)
} else {
None
}
}
};
(stop_price, target_price)
}
/// Calculate backtest metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
returns: &[f64],
trades: &[Trade],
_streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
// Calculate max drawdown duration
let max_drawdown_duration = self.calculate_max_drawdown_duration(drawdown_curve);
// Trade statistics
let total_trades = trades.len();
// Separate closed vs open trades (EndOfData means still open)
let total_open_trades =
trades.iter().filter(|t| matches!(t.exit_reason, ExitReason::EndOfData)).count();
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
// Open trade PnL
let open_trade_pnl: f64 = trades
.iter()
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
.map(|t| t.pnl)
.sum();
// Only count closed trades for win/loss statistics
let closed_trades: Vec<_> =
trades.iter().filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData)).collect();
let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_closed_trades > 0 {
winning_trades as f64 / total_closed_trades as f64 * 100.0
} else {
0.0
};
// Total fees paid
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
// Best and worst trade
let best_trade_pct =
trades.iter().map(|t| t.return_pct).fold(f64::NEG_INFINITY, |a, b| a.max(b));
let best_trade_pct = if best_trade_pct.is_infinite() { 0.0 } else { best_trade_pct };
let worst_trade_pct =
trades.iter().map(|t| t.return_pct).fold(f64::INFINITY, |a, b| a.min(b));
let worst_trade_pct = if worst_trade_pct.is_infinite() { 0.0 } else { worst_trade_pct };
// Profit factor (based on closed trades)
let gross_profit: f64 = closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 =
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
// Expectancy = average trade PnL
let expectancy = if total_closed_trades > 0 {
closed_trades.iter().map(|t| t.pnl).sum::<f64>() / total_closed_trades as f64
} else {
0.0
};
// SQN = (Expectancy / StdDev of trade PnL) * sqrt(total trades)
let sqn = if total_closed_trades > 1 {
let trade_pnls: Vec<f64> = closed_trades.iter().map(|t| t.pnl).collect();
let mean = expectancy;
let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::<f64>()
/ (total_closed_trades - 1) as f64;
let std_dev = variance.sqrt();
if std_dev > 0.0 {
(mean / std_dev) * (total_closed_trades as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Average returns
let avg_trade_return_pct = if total_trades > 0 {
trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64
} else {
0.0
};
let avg_win_pct = if winning_trades > 0 {
closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::<f64>()
/ winning_trades as f64
} else {
0.0
};
let avg_loss_pct = if losing_trades > 0 {
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
/ losing_trades as f64
} else {
0.0
};
// Average winning/losing trade duration
let avg_winning_duration = if winning_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl > 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ winning_trades as f64
} else {
0.0
};
let avg_losing_duration = if losing_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl < 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ losing_trades as f64
} else {
0.0
};
// Consecutive wins/losses
let (max_consecutive_wins, max_consecutive_losses) = self.calculate_consecutive(trades);
// Holding period
let avg_holding_period = if total_trades > 0 {
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>() / total_trades as f64
} else {
0.0
};
// Exposure (time in market)
let bars_in_position: usize = trades.iter().map(|t| t.holding_period()).sum();
let exposure_pct = if !equity_curve.is_empty() {
bars_in_position as f64 / equity_curve.len() as f64 * 100.0
} else {
0.0
};
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
// Calmar ratio: CAGR / max drawdown
let num_periods = equity_curve.len().max(1) as f64;
let years = num_periods / 365.25; // Convert to years using 365.25 days
let total_return_frac = total_return_pct / 100.0;
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
let cagr =
if years > 0.0 { (1.0 + total_return_frac).powf(1.0 / years) - 1.0 } else { 0.0 };
let calmar_ratio = if max_drawdown_pct > 0.0 {
cagr / (max_drawdown_pct / 100.0) // Both as fractions
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Recovery factor: net profit / max drawdown (absolute value)
let net_profit = end_value - start_value;
let recovery_factor = if max_drawdown_pct > 0.0 && start_value > 0.0 {
let max_dd_absolute = max_drawdown_pct / 100.0 * start_value;
if max_dd_absolute > 0.0 {
net_profit / max_dd_absolute
} else {
0.0
}
} else if net_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
omega_ratio,
max_drawdown_pct,
max_drawdown_duration,
win_rate_pct,
profit_factor,
expectancy,
sqn,
total_trades,
total_closed_trades,
total_open_trades,
open_trade_pnl,
winning_trades,
losing_trades,
start_value,
end_value,
total_fees_paid,
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,
max_consecutive_losses,
avg_holding_period,
exposure_pct,
payoff_ratio,
recovery_factor,
}
}
/// Calculate max drawdown duration from drawdown curve.
fn calculate_max_drawdown_duration(&self, drawdown_curve: &[f64]) -> usize {
let mut max_duration = 0;
let mut current_duration = 0;
for &dd in drawdown_curve {
if dd > 0.0 {
current_duration += 1;
max_duration = max_duration.max(current_duration);
} else {
current_duration = 0;
}
}
max_duration
}
/// Calculate max consecutive wins and losses.
fn calculate_consecutive(&self, trades: &[Trade]) -> (usize, usize) {
let mut max_wins = 0;
let mut max_losses = 0;
let mut current_wins = 0;
let mut current_losses = 0;
for trade in trades {
if trade.pnl > 0.0 {
current_wins += 1;
current_losses = 0;
max_wins = max_wins.max(current_wins);
} else if trade.pnl < 0.0 {
current_losses += 1;
current_wins = 0;
max_losses = max_losses.max(current_losses);
}
}
(max_wins, max_losses)
}
/// Calculate risk-adjusted metrics from daily portfolio returns.
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
/// Uses 365 calendar days for annualization.
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
if returns.len() < 2 {
return (0.0, 0.0, 1.0);
}
// 365 calendar days for annualization
let periods_per_year: f64 = 365.0;
let _n = returns.len() as f64;
// Filter out NaN values
let valid_returns: Vec<f64> = returns.iter().filter(|r| !r.is_nan()).copied().collect();
if valid_returns.len() < 2 {
return (0.0, 0.0, 1.0);
}
let n_valid = valid_returns.len() as f64;
// Calculate mean return
let mean = valid_returns.iter().sum::<f64>() / n_valid;
// Calculate standard deviation
let variance =
valid_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n_valid - 1.0);
let std_dev = variance.sqrt();
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
let sharpe_ratio =
if std_dev > 0.0 { (mean / std_dev) * periods_per_year.sqrt() } else { 0.0 };
// Sortino Ratio - uses downside deviation (only negative returns)
let downside_returns: Vec<f64> =
valid_returns.iter().filter(|&&r| r < 0.0).copied().collect();
let downside_variance = if !downside_returns.is_empty() {
downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / n_valid // Divide by total count, not downside count
} else {
0.0
};
let downside_std = downside_variance.sqrt();
let sortino_ratio = if downside_std > 0.0 {
(mean / downside_std) * periods_per_year.sqrt()
} else if mean > 0.0 {
f64::INFINITY
} else {
0.0
};
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
// With threshold = 0
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
let sum_negative: f64 = valid_returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum();
let omega_ratio = if sum_negative > 0.0 {
sum_positive / sum_negative
} else if sum_positive > 0.0 {
f64::INFINITY
} else {
1.0
};
(sharpe_ratio, sortino_ratio, omega_ratio)
}
}
/// Compute `BacktestMetrics` from pre-built curves and trade list.
///
/// Exposed as a standalone function so non-OHLCV strategies (e.g. tick backtest)
/// can produce identical metrics without duplicating the calculation logic.
pub fn compute_backtest_metrics(
equity_curve: &[f64],
drawdown_curve: &[f64],
returns: &[f64],
trades: &[Trade],
initial_capital: f64,
) -> BacktestMetrics {
// Delegate to a throwaway engine instance — avoids duplicating the logic.
let engine = PortfolioEngine::new(BacktestConfig {
initial_capital,
..Default::default()
});
engine.calculate_metrics(equity_curve, drawdown_curve, returns, trades, &StreamingMetrics::new())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_ohlcv() -> OhlcvData {
OhlcvData {
timestamps: (0..20).map(|i| i as i64).collect(),
open: vec![
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
],
high: vec![
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
],
low: vec![
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
],
close: vec![
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
],
volume: vec![1000.0; 20],
}
}
fn sample_signals() -> CompiledSignals {
CompiledSignals {
symbol: "TEST".to_string(),
entries: vec![
false, true, false, false, false, false, false, false, false, false, false, true,
false, false, false, false, false, false, false, false,
],
exits: vec![
false, false, false, false, false, true, false, false, false, false, false, false,
false, false, false, true, false, false, false, false,
],
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
}
}
#[test]
fn test_basic_backtest() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
let ohlcv = sample_ohlcv();
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// Should have 2 trades
assert_eq!(result.trades.len(), 2);
// First trade: entry at 101.5, exit at 105.0
let trade1 = &result.trades[0];
assert!((trade1.entry_price - 101.5).abs() < 1e-10);
assert!((trade1.exit_price - 105.0).abs() < 1e-10);
assert!(trade1.pnl > 0.0); // Profitable
// Equity curve should have correct length
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_with_fees() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.001, // 0.1%
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
let ohlcv = sample_ohlcv();
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// Trades should have fees deducted
for trade in &result.trades {
assert!(trade.fees > 0.0);
}
}
#[test]
fn test_with_stop_loss() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
// Create data where stop would be hit
let mut ohlcv = sample_ohlcv();
// Add a big drop after entry
ohlcv.low[3] = 95.0; // Big drop
ohlcv.close[3] = 96.0;
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// First trade should exit on stop loss
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Portfolio simulation engine for RaptorBT.
pub mod allocation;
pub mod engine;
pub mod monte_carlo;
pub mod position;
pub use allocation::{AllocationStrategy, CapitalAllocator};
pub use engine::PortfolioEngine;
pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult};
pub use position::PositionManager;
+361
View File
@@ -0,0 +1,361 @@
//! Monte Carlo forward simulation for portfolio projection.
//!
//! Uses Geometric Brownian Motion (GBM) with Cholesky decomposition
//! for correlated multi-asset simulation. Parallelized via Rayon.
use rayon::prelude::*;
/// Configuration for Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloConfig {
pub n_simulations: usize,
pub horizon_days: usize,
pub seed: u64,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self { n_simulations: 10_000, horizon_days: 252, seed: 42 }
}
}
/// Result of a Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloResult {
/// Percentile paths: Vec of (percentile, path_values)
pub percentile_paths: Vec<(f64, Vec<f64>)>,
/// Terminal value for each simulation
pub final_values: Vec<f64>,
/// Expected annualized return
pub expected_return: f64,
/// Probability of loss (final value < initial value)
pub probability_of_loss: f64,
/// Value at Risk at 95% confidence
pub var_95: f64,
/// Conditional Value at Risk at 95% confidence
pub cvar_95: f64,
}
/// Cholesky decomposition of a symmetric positive-definite matrix.
/// Returns lower-triangular matrix L such that A = L * L^T.
fn cholesky(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, &'static str> {
let n = matrix.len();
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = 0.0;
for k in 0..j {
sum += l[i][k] * l[j][k];
}
if i == j {
let diag = matrix[i][i] - sum;
if diag <= 0.0 {
// Matrix is not positive definite; use a small epsilon
l[i][j] = (diag.abs().max(1e-10)).sqrt();
} else {
l[i][j] = diag.sqrt();
}
} else {
if l[j][j].abs() < 1e-15 {
l[i][j] = 0.0;
} else {
l[i][j] = (matrix[i][j] - sum) / l[j][j];
}
}
}
}
Ok(l)
}
/// Simple xoshiro256** PRNG for deterministic parallel simulation.
#[derive(Clone)]
struct Xoshiro256 {
s: [u64; 4],
}
impl Xoshiro256 {
fn new(seed: u64) -> Self {
// SplitMix64 to seed all 4 state words
let mut z = seed;
let mut s = [0u64; 4];
for item in &mut s {
z = z.wrapping_add(0x9e3779b97f4a7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
*item = z ^ (z >> 31);
}
Self { s }
}
fn jump(&mut self) {
// Jump function: advances state by 2^128 calls
const JUMP: [u64; 4] =
[0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c];
let mut s0: u64 = 0;
let mut s1: u64 = 0;
let mut s2: u64 = 0;
let mut s3: u64 = 0;
for j in &JUMP {
for b in 0..64 {
if j & (1u64 << b) != 0 {
s0 ^= self.s[0];
s1 ^= self.s[1];
s2 ^= self.s[2];
s3 ^= self.s[3];
}
self.next_u64();
}
}
self.s[0] = s0;
self.s[1] = s1;
self.s[2] = s2;
self.s[3] = s3;
}
fn next_u64(&mut self) -> u64 {
let result = (self.s[1].wrapping_mul(5)).rotate_left(7).wrapping_mul(9);
let t = self.s[1] << 17;
self.s[2] ^= self.s[0];
self.s[3] ^= self.s[1];
self.s[1] ^= self.s[2];
self.s[0] ^= self.s[3];
self.s[2] ^= t;
self.s[3] = self.s[3].rotate_left(45);
result
}
/// Generate uniform f64 in [0, 1).
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
/// Box-Muller transform for standard normal.
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64().max(1e-15);
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
/// Core Monte Carlo simulation function.
///
/// # Arguments
/// * `returns` - Per-strategy daily returns (N strategies x T days each)
/// * `weights` - Portfolio weights (length N, must sum to 1)
/// * `correlation_matrix` - N x N correlation matrix
/// * `initial_value` - Starting portfolio value
/// * `config` - Simulation configuration
pub fn simulate_portfolio_forward(
returns: &[Vec<f64>],
weights: &[f64],
correlation_matrix: &[Vec<f64>],
initial_value: f64,
config: &MonteCarloConfig,
) -> MonteCarloResult {
let n_assets = returns.len();
let dt = 1.0; // daily time step
// Compute per-asset mean and std of historical returns
let mut mus = vec![0.0; n_assets];
let mut sigmas = vec![0.0; n_assets];
for (i, ret) in returns.iter().enumerate() {
if ret.is_empty() {
continue;
}
let mean = ret.iter().sum::<f64>() / ret.len() as f64;
let var = ret.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / ret.len() as f64;
mus[i] = mean;
sigmas[i] = var.sqrt().max(1e-10);
}
// Cholesky decomposition of correlation matrix
let chol = cholesky(correlation_matrix).unwrap_or_else(|_| {
// Fallback: identity matrix (independent assets)
let mut identity = vec![vec![0.0; n_assets]; n_assets];
for i in 0..n_assets {
identity[i][i] = 1.0;
}
identity
});
// Prepare a base RNG and create per-chunk seeds via jumping
let mut base_rng = Xoshiro256::new(config.seed);
let n_chunks = rayon::current_num_threads().max(1);
let chunk_size = (config.n_simulations + n_chunks - 1) / n_chunks;
let chunk_rngs: Vec<Xoshiro256> = (0..n_chunks)
.map(|_| {
let rng = base_rng.clone();
base_rng.jump();
rng
})
.collect();
// Run simulations in parallel chunks
let all_paths: Vec<Vec<f64>> = chunk_rngs
.into_par_iter()
.enumerate()
.flat_map(|(chunk_idx, mut rng)| {
let start = chunk_idx * chunk_size;
let end = (start + chunk_size).min(config.n_simulations);
let mut chunk_paths = Vec::with_capacity(end - start);
for _ in start..end {
let mut portfolio_value = initial_value;
let mut path = Vec::with_capacity(config.horizon_days + 1);
path.push(portfolio_value);
for _ in 0..config.horizon_days {
// Generate N independent standard normals
let z_indep: Vec<f64> = (0..n_assets).map(|_| rng.next_normal()).collect();
// Correlate via Cholesky: z_corr = L * z_indep
let mut z_corr = vec![0.0; n_assets];
for i in 0..n_assets {
for j in 0..=i {
z_corr[i] += chol[i][j] * z_indep[j];
}
}
// GBM per asset, then weighted portfolio return
let mut portfolio_return = 0.0;
for i in 0..n_assets {
let drift = (mus[i] - 0.5 * sigmas[i].powi(2)) * dt;
let diffusion = sigmas[i] * dt.sqrt() * z_corr[i];
let asset_return = (drift + diffusion).exp() - 1.0;
portfolio_return += weights[i] * asset_return;
}
portfolio_value *= 1.0 + portfolio_return;
path.push(portfolio_value);
}
chunk_paths.push(path);
}
chunk_paths
})
.collect();
// Extract final values
let mut final_values: Vec<f64> = all_paths.iter().map(|p| *p.last().unwrap()).collect();
final_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = final_values.len();
// Percentile paths: find simulations closest to each percentile's final value
let percentiles = [5.0, 25.0, 50.0, 75.0, 95.0];
let percentile_paths: Vec<(f64, Vec<f64>)> = percentiles
.iter()
.map(|&pct| {
let idx = ((pct / 100.0) * (n as f64 - 1.0)).round() as usize;
let target_final = final_values[idx.min(n - 1)];
// Find the simulation path whose final value is closest to target
let best_idx = all_paths
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let da = (a.last().unwrap() - target_final).abs();
let db = (b.last().unwrap() - target_final).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
(pct, all_paths[best_idx].clone())
})
.collect();
// Expected return (annualized from mean of final values)
let mean_final = final_values.iter().sum::<f64>() / n as f64;
let expected_return = (mean_final / initial_value - 1.0) * 100.0;
// Probability of loss
let n_loss = final_values.iter().filter(|&&v| v < initial_value).count();
let probability_of_loss = n_loss as f64 / n as f64;
// VaR 95%: 5th percentile loss
let p5_idx = ((0.05 * (n as f64 - 1.0)).round() as usize).min(n - 1);
let var_95 = ((initial_value - final_values[p5_idx]) / initial_value * 100.0).max(0.0);
// CVaR 95%: average of losses below VaR
let cvar_values = &final_values[..=p5_idx];
let cvar_95 = if cvar_values.is_empty() {
var_95
} else {
let avg_tail = cvar_values.iter().sum::<f64>() / cvar_values.len() as f64;
((initial_value - avg_tail) / initial_value * 100.0).max(0.0)
};
MonteCarloResult {
percentile_paths,
final_values,
expected_return,
probability_of_loss,
var_95,
cvar_95,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cholesky_identity() {
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let l = cholesky(&matrix).unwrap();
assert!((l[0][0] - 1.0).abs() < 1e-10);
assert!((l[1][1] - 1.0).abs() < 1e-10);
assert!(l[0][1].abs() < 1e-10);
assert!(l[1][0].abs() < 1e-10);
}
#[test]
fn test_cholesky_correlated() {
let matrix = vec![vec![1.0, 0.5], vec![0.5, 1.0]];
let l = cholesky(&matrix).unwrap();
// Verify L * L^T = matrix
let reconstructed_00 = l[0][0] * l[0][0];
let reconstructed_01 = l[1][0] * l[0][0];
let reconstructed_11 = l[1][0] * l[1][0] + l[1][1] * l[1][1];
assert!((reconstructed_00 - 1.0).abs() < 1e-10);
assert!((reconstructed_01 - 0.5).abs() < 1e-10);
assert!((reconstructed_11 - 1.0).abs() < 1e-10);
}
#[test]
fn test_simulate_basic() {
// Two assets with identical positive returns
let returns = vec![vec![0.001; 252], vec![0.001; 252]];
let weights = vec![0.5, 0.5];
let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let config = MonteCarloConfig { n_simulations: 100, horizon_days: 10, seed: 42 };
let result = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
assert_eq!(result.final_values.len(), 100);
assert_eq!(result.percentile_paths.len(), 5);
// Expected return should be positive given positive drift
assert!(result.expected_return > -50.0); // Sanity check
}
#[test]
fn test_deterministic() {
let returns = vec![vec![0.001; 100], vec![-0.0005; 100]];
let weights = vec![0.6, 0.4];
let corr = vec![vec![1.0, -0.3], vec![-0.3, 1.0]];
let config = MonteCarloConfig { n_simulations: 50, horizon_days: 20, seed: 123 };
let r1 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
let r2 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
// Same seed should produce same final values (single-threaded determinism)
// Note: with rayon, parallelism may affect order but not values
assert!((r1.expected_return - r2.expected_return).abs() < 1e-6);
}
}
+347
View File
@@ -0,0 +1,347 @@
//! Position tracking for portfolio management.
use crate::core::types::{Direction, ExitReason, Position, Price, Timestamp, Trade};
/// Position manager for tracking open positions.
#[derive(Debug, Clone)]
pub struct PositionManager {
/// Current position state.
pub position: Position,
/// Trade counter for generating unique IDs.
trade_counter: u64,
/// Symbol being traded.
pub symbol: String,
}
impl PositionManager {
/// Create a new position manager.
pub fn new(symbol: String) -> Self {
Self { position: Position::new(), trade_counter: 0, symbol }
}
/// Check if currently in a position.
#[inline]
pub fn is_in_position(&self) -> bool {
self.position.is_open
}
/// Get current position direction.
pub fn current_direction(&self) -> Option<Direction> {
if self.position.is_open {
Some(self.position.direction)
} else {
None
}
}
/// Open a new position.
///
/// # Arguments
/// * `idx` - Bar index
/// * `timestamp` - Entry timestamp
/// * `price` - Entry price
/// * `size` - Position size
/// * `direction` - Trade direction
/// * `stop_price` - Optional stop-loss price
/// * `target_price` - Optional take-profit price
/// * `entry_fees` - Entry fees (to track for PnL calculation)
///
/// # Returns
/// True if position was opened, false if already in position
pub fn open_position(
&mut self,
idx: usize,
_timestamp: Timestamp,
price: Price,
size: f64,
direction: Direction,
stop_price: Option<Price>,
target_price: Option<Price>,
entry_fees: f64,
) -> bool {
if self.position.is_open {
return false;
}
self.position.open(idx, price, size, direction, stop_price, target_price, entry_fees);
true
}
/// Close current position and generate a trade record.
///
/// # Arguments
/// * `idx` - Bar index
/// * `timestamp` - Exit timestamp
/// * `price` - Exit price
/// * `entry_timestamp` - Entry timestamp (for trade record)
/// * `exit_reason` - Reason for exit
/// * `fees` - Transaction fees
///
/// # Returns
/// Trade record if position was closed, None if no position
pub fn close_position(
&mut self,
idx: usize,
timestamp: Timestamp,
price: Price,
entry_timestamp: Timestamp,
exit_reason: ExitReason,
fees: f64,
) -> Option<Trade> {
if !self.position.is_open {
return None;
}
let trade = self.create_trade(idx, timestamp, price, entry_timestamp, exit_reason, fees);
self.position.close();
self.trade_counter += 1;
Some(trade)
}
/// Create a trade record from current position.
fn create_trade(
&self,
exit_idx: usize,
exit_timestamp: Timestamp,
exit_price: Price,
entry_timestamp: Timestamp,
exit_reason: ExitReason,
exit_fees: f64,
) -> Trade {
let pos = &self.position;
let multiplier = pos.direction.multiplier();
// Calculate P&L: gross - entry_fees - exit_fees
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
let total_fees = pos.entry_fees + exit_fees;
let pnl = gross_pnl - total_fees;
// Calculate return percentage
let cost_basis = pos.entry_price * pos.size;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
Trade {
id: self.trade_counter,
symbol: self.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: pos.direction,
pnl,
return_pct,
entry_time: entry_timestamp,
exit_time: exit_timestamp,
fees: total_fees,
exit_reason,
}
}
/// Update position with new price data (for trailing stops).
///
/// # Arguments
/// * `high` - Current bar high
/// * `low` - Current bar low
pub fn update_price(&mut self, high: Price, low: Price) {
if self.position.is_open {
self.position.update_extremes(high, low);
}
}
/// Calculate unrealized P&L at current price.
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
self.position.unrealized_pnl(current_price)
}
/// Get current position value (market value of position).
pub fn position_value(&self, current_price: Price) -> f64 {
if !self.position.is_open {
return 0.0;
}
current_price * self.position.size
}
/// Calculate position exposure (notional value as fraction of given capital).
pub fn exposure(&self, current_price: Price, capital: f64) -> f64 {
if capital <= 0.0 {
return 0.0;
}
self.position_value(current_price) / capital
}
/// Check if stop-loss is hit.
pub fn is_stop_hit(&self, low: Price, high: Price) -> bool {
if !self.position.is_open {
return false;
}
if let Some(stop) = self.position.stop_price {
match self.position.direction {
Direction::Long => low <= stop,
Direction::Short => high >= stop,
}
} else {
false
}
}
/// Check if take-profit is hit.
pub fn is_target_hit(&self, low: Price, high: Price) -> bool {
if !self.position.is_open {
return false;
}
if let Some(target) = self.position.target_price {
match self.position.direction {
Direction::Long => high >= target,
Direction::Short => low <= target,
}
} else {
false
}
}
/// Update trailing stop.
///
/// # Arguments
/// * `trail_percent` - Trailing stop percentage
pub fn update_trailing_stop(&mut self, trail_percent: f64) {
if !self.position.is_open {
return;
}
match self.position.direction {
Direction::Long => {
// Trail below highest price since entry
let new_stop = self.position.highest_since_entry * (1.0 - trail_percent);
if let Some(current_stop) = self.position.stop_price {
if new_stop > current_stop {
self.position.stop_price = Some(new_stop);
}
} else {
self.position.stop_price = Some(new_stop);
}
}
Direction::Short => {
// Trail above lowest price since entry
let new_stop = self.position.lowest_since_entry * (1.0 + trail_percent);
if let Some(current_stop) = self.position.stop_price {
if new_stop < current_stop {
self.position.stop_price = Some(new_stop);
}
} else {
self.position.stop_price = Some(new_stop);
}
}
}
}
/// Reset position manager for new backtest.
pub fn reset(&mut self) {
self.position = Position::new();
self.trade_counter = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_close_position() {
let mut pm = PositionManager::new("TEST".to_string());
// Open position
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0));
assert!(pm.is_in_position());
// Try to open another - should fail
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None, 0.0));
// Close position with profit
let trade = pm.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0).unwrap();
assert!(!pm.is_in_position());
assert_eq!(trade.entry_idx, 0);
assert_eq!(trade.exit_idx, 5);
assert!((trade.entry_price - 100.0).abs() < 1e-10);
assert!((trade.exit_price - 110.0).abs() < 1e-10);
// P&L: (110 - 100) * 10 - 2 = 98
assert!((trade.pnl - 98.0).abs() < 1e-10);
}
#[test]
fn test_short_position() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None, 0.0);
// Close with profit (price went down)
let trade = pm.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0).unwrap();
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
assert!((trade.pnl - 98.0).abs() < 1e-10);
}
#[test]
fn test_stop_loss() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(
0,
1000,
100.0,
10.0,
Direction::Long,
Some(95.0), // Stop at 95
None,
0.0,
);
// Check stop not hit
assert!(!pm.is_stop_hit(96.0, 102.0));
// Check stop hit
assert!(pm.is_stop_hit(94.0, 102.0));
}
#[test]
fn test_trailing_stop() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
// Update with higher price
pm.update_price(110.0, 98.0);
pm.update_trailing_stop(0.05); // 5% trail
// Stop should be at 110 * 0.95 = 104.5
assert!((pm.position.stop_price.unwrap() - 104.5).abs() < 1e-10);
// Update with even higher price
pm.update_price(120.0, 108.0);
pm.update_trailing_stop(0.05);
// Stop should move up to 120 * 0.95 = 114
assert!((pm.position.stop_price.unwrap() - 114.0).abs() < 1e-10);
}
#[test]
fn test_unrealized_pnl() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
// Price up
let pnl = pm.unrealized_pnl(110.0);
assert!((pnl - 100.0).abs() < 1e-10); // (110 - 100) * 10 = 100
// Price down
let pnl = pm.unrealized_pnl(95.0);
assert!((pnl - (-50.0)).abs() < 1e-10); // (95 - 100) * 10 = -50
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
//! Python bindings for RaptorBT.
pub mod bindings;
pub mod numpy_bridge;
+34
View File
@@ -0,0 +1,34 @@
//! Zero-copy numpy array interface.
use numpy::{PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
/// Convert numpy array to Vec<f64>.
pub fn numpy_to_vec_f64(arr: PyReadonlyArray1<f64>) -> Vec<f64> {
arr.as_slice().unwrap().to_vec()
}
/// Convert numpy array to Vec<i64>.
pub fn numpy_to_vec_i64(arr: PyReadonlyArray1<i64>) -> Vec<i64> {
arr.as_slice().unwrap().to_vec()
}
/// Convert numpy bool array to Vec<bool>.
pub fn numpy_to_vec_bool(arr: PyReadonlyArray1<bool>) -> Vec<bool> {
arr.as_slice().unwrap().to_vec()
}
/// Convert Vec<f64> to numpy array.
pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec<f64>) -> &'py PyArray1<f64> {
PyArray1::from_vec(py, vec)
}
/// Convert Vec<i64> to numpy array.
pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec<i64>) -> &'py PyArray1<i64> {
PyArray1::from_vec(py, vec)
}
/// Convert Vec<bool> to numpy array.
pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec<bool>) -> &'py PyArray1<bool> {
PyArray1::from_vec(py, vec)
}
+456
View File
@@ -0,0 +1,456 @@
//! Expression evaluation for signal generation.
//!
//! Provides a Rust-native expression evaluator for generating trading signals
//! from indicator values.
/// Comparison operators for signal generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompareOp {
/// Greater than.
Gt,
/// Greater than or equal.
Gte,
/// Less than.
Lt,
/// Less than or equal.
Lte,
/// Equal (within tolerance).
Eq,
/// Not equal.
Ne,
}
/// Crossover/crossunder detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossType {
/// Line A crosses above line B.
CrossOver,
/// Line A crosses below line B.
CrossUnder,
}
/// Compare two series element-wise.
///
/// # Arguments
/// * `a` - First series
/// * `b` - Second series
/// * `op` - Comparison operator
///
/// # Returns
/// Boolean series indicating where comparison is true
pub fn compare(a: &[f64], b: &[f64], op: CompareOp) -> Vec<bool> {
let n = a.len();
assert_eq!(n, b.len());
let tolerance = 1e-10;
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() || b[i].is_nan() {
continue;
}
result[i] = match op {
CompareOp::Gt => a[i] > b[i],
CompareOp::Gte => a[i] >= b[i],
CompareOp::Lt => a[i] < b[i],
CompareOp::Lte => a[i] <= b[i],
CompareOp::Eq => (a[i] - b[i]).abs() < tolerance,
CompareOp::Ne => (a[i] - b[i]).abs() >= tolerance,
};
}
result
}
/// Compare series with a scalar value.
///
/// # Arguments
/// * `a` - Series
/// * `value` - Scalar value to compare against
/// * `op` - Comparison operator
///
/// # Returns
/// Boolean series indicating where comparison is true
pub fn compare_scalar(a: &[f64], value: f64, op: CompareOp) -> Vec<bool> {
let n = a.len();
let tolerance = 1e-10;
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() {
continue;
}
result[i] = match op {
CompareOp::Gt => a[i] > value,
CompareOp::Gte => a[i] >= value,
CompareOp::Lt => a[i] < value,
CompareOp::Lte => a[i] <= value,
CompareOp::Eq => (a[i] - value).abs() < tolerance,
CompareOp::Ne => (a[i] - value).abs() >= tolerance,
};
}
result
}
/// Detect crossover/crossunder between two series.
///
/// Crossover: a crosses above b (a[i-1] < b[i-1] and a[i] > b[i])
/// Crossunder: a crosses below b (a[i-1] > b[i-1] and a[i] < b[i])
///
/// # Arguments
/// * `a` - First series
/// * `b` - Second series
/// * `cross_type` - Type of cross to detect
///
/// # Returns
/// Boolean series indicating where cross occurs
pub fn cross(a: &[f64], b: &[f64], cross_type: CrossType) -> Vec<bool> {
let n = a.len();
assert_eq!(n, b.len());
let mut result = vec![false; n];
if n < 2 {
return result;
}
for i in 1..n {
if a[i].is_nan() || b[i].is_nan() || a[i - 1].is_nan() || b[i - 1].is_nan() {
continue;
}
result[i] = match cross_type {
CrossType::CrossOver => a[i - 1] <= b[i - 1] && a[i] > b[i],
CrossType::CrossUnder => a[i - 1] >= b[i - 1] && a[i] < b[i],
};
}
result
}
/// Detect crossover with a scalar value.
///
/// # Arguments
/// * `a` - Series
/// * `value` - Scalar value to cross
/// * `cross_type` - Type of cross to detect
///
/// # Returns
/// Boolean series indicating where cross occurs
pub fn cross_scalar(a: &[f64], value: f64, cross_type: CrossType) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if n < 2 {
return result;
}
for i in 1..n {
if a[i].is_nan() || a[i - 1].is_nan() {
continue;
}
result[i] = match cross_type {
CrossType::CrossOver => a[i - 1] <= value && a[i] > value,
CrossType::CrossUnder => a[i - 1] >= value && a[i] < value,
};
}
result
}
/// Check if value is in a range.
///
/// # Arguments
/// * `a` - Series
/// * `low` - Lower bound
/// * `high` - Upper bound
///
/// # Returns
/// Boolean series indicating where value is in range [low, high]
pub fn in_range(a: &[f64], low: f64, high: f64) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() {
continue;
}
result[i] = a[i] >= low && a[i] <= high;
}
result
}
/// Check if series is rising (current > previous).
///
/// # Arguments
/// * `a` - Series
/// * `periods` - Number of periods to look back (default: 1)
///
/// # Returns
/// Boolean series indicating where value is rising
pub fn is_rising(a: &[f64], periods: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if periods >= n {
return result;
}
for i in periods..n {
if a[i].is_nan() || a[i - periods].is_nan() {
continue;
}
result[i] = a[i] > a[i - periods];
}
result
}
/// Check if series is falling (current < previous).
///
/// # Arguments
/// * `a` - Series
/// * `periods` - Number of periods to look back (default: 1)
///
/// # Returns
/// Boolean series indicating where value is falling
pub fn is_falling(a: &[f64], periods: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if periods >= n {
return result;
}
for i in periods..n {
if a[i].is_nan() || a[i - periods].is_nan() {
continue;
}
result[i] = a[i] < a[i - periods];
}
result
}
/// Check if value has been above a threshold for n consecutive bars.
///
/// # Arguments
/// * `a` - Series
/// * `threshold` - Threshold value
/// * `consecutive` - Number of consecutive bars required
///
/// # Returns
/// Boolean series indicating where condition is met
pub fn above_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if consecutive > n {
return result;
}
for i in (consecutive - 1)..n {
let mut all_above = true;
for j in 0..consecutive {
let idx = i - j;
if a[idx].is_nan() || a[idx] <= threshold {
all_above = false;
break;
}
}
result[i] = all_above;
}
result
}
/// Check if value has been below a threshold for n consecutive bars.
///
/// # Arguments
/// * `a` - Series
/// * `threshold` - Threshold value
/// * `consecutive` - Number of consecutive bars required
///
/// # Returns
/// Boolean series indicating where condition is met
pub fn below_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if consecutive > n {
return result;
}
for i in (consecutive - 1)..n {
let mut all_below = true;
for j in 0..consecutive {
let idx = i - j;
if a[idx].is_nan() || a[idx] >= threshold {
all_below = false;
break;
}
}
result[i] = all_below;
}
result
}
/// Detect highest value in rolling window.
///
/// # Arguments
/// * `a` - Series
/// * `window` - Window size
///
/// # Returns
/// Boolean series indicating where current value is highest in window
pub fn is_highest(a: &[f64], window: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if window > n || window == 0 {
return result;
}
for i in (window - 1)..n {
let start = i + 1 - window;
let current = a[i];
if current.is_nan() {
continue;
}
let max_in_window =
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::NEG_INFINITY, |a, &b| a.max(b));
result[i] = (current - max_in_window).abs() < 1e-10;
}
result
}
/// Detect lowest value in rolling window.
///
/// # Arguments
/// * `a` - Series
/// * `window` - Window size
///
/// # Returns
/// Boolean series indicating where current value is lowest in window
pub fn is_lowest(a: &[f64], window: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if window > n || window == 0 {
return result;
}
for i in (window - 1)..n {
let start = i + 1 - window;
let current = a[i];
if current.is_nan() {
continue;
}
let min_in_window =
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::INFINITY, |a, &b| a.min(b));
result[i] = (current - min_in_window).abs() < 1e-10;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compare() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![2.0, 2.0, 2.0, 2.0];
let result = compare(&a, &b, CompareOp::Gt);
assert!(!result[0]); // 1 > 2 = false
assert!(!result[1]); // 2 > 2 = false
assert!(result[2]); // 3 > 2 = true
assert!(result[3]); // 4 > 2 = true
}
#[test]
fn test_crossover() {
let a = vec![1.0, 1.5, 2.5, 3.0, 2.5];
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
let result = cross(&a, &b, CrossType::CrossOver);
assert!(!result[0]); // No previous
assert!(!result[1]); // 1.0 < 2.0, 1.5 < 2.0 - still below
assert!(result[2]); // 1.5 < 2.0, 2.5 > 2.0 - crossed over!
assert!(!result[3]); // 2.5 > 2.0, 3.0 > 2.0 - already above
assert!(!result[4]); // 3.0 > 2.0, 2.5 > 2.0 - still above
}
#[test]
fn test_crossunder() {
let a = vec![3.0, 2.5, 1.5, 1.0, 1.5];
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
let result = cross(&a, &b, CrossType::CrossUnder);
assert!(!result[0]); // No previous
assert!(!result[1]); // 3.0 > 2.0, 2.5 > 2.0 - still above
assert!(result[2]); // 2.5 > 2.0, 1.5 < 2.0 - crossed under!
assert!(!result[3]); // 1.5 < 2.0, 1.0 < 2.0 - already below
assert!(!result[4]); // 1.0 < 2.0, 1.5 < 2.0 - still below
}
#[test]
fn test_in_range() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = in_range(&a, 2.0, 4.0);
assert!(!result[0]); // 1 not in [2, 4]
assert!(result[1]); // 2 in [2, 4]
assert!(result[2]); // 3 in [2, 4]
assert!(result[3]); // 4 in [2, 4]
assert!(!result[4]); // 5 not in [2, 4]
}
#[test]
fn test_is_rising() {
let a = vec![1.0, 2.0, 3.0, 2.5, 3.5];
let result = is_rising(&a, 1);
assert!(!result[0]); // No previous
assert!(result[1]); // 2 > 1
assert!(result[2]); // 3 > 2
assert!(!result[3]); // 2.5 < 3
assert!(result[4]); // 3.5 > 2.5
}
#[test]
fn test_above_for() {
let a = vec![1.0, 3.0, 3.5, 4.0, 2.0, 3.0];
let threshold = 2.5;
let result = above_for(&a, threshold, 3);
assert!(!result[0]);
assert!(!result[1]);
assert!(!result[2]); // 1.0 < 2.5
assert!(result[3]); // 3.0, 3.5, 4.0 all > 2.5
assert!(!result[4]); // 2.0 < 2.5
assert!(!result[5]);
}
#[test]
fn test_is_highest() {
let a = vec![1.0, 3.0, 2.0, 4.0, 3.5];
let result = is_highest(&a, 3);
assert!(!result[0]);
assert!(!result[1]);
assert!(result[2] == false); // 2.0 is not highest in [1.0, 3.0, 2.0]
assert!(result[3]); // 4.0 is highest in [3.0, 2.0, 4.0]
assert!(!result[4]); // 3.5 is not highest in [2.0, 4.0, 3.5]
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Signal processing for RaptorBT.
//!
//! This module handles signal cleaning, synchronization, and expression evaluation.
pub mod expression;
pub mod processor;
pub mod synchronizer;
pub mod tick_signals;
pub use processor::SignalProcessor;
pub use synchronizer::{SignalSynchronizer, SyncMode};
pub use tick_signals::{tick_momentum_entry, tick_momentum_exit};
+427
View File
@@ -0,0 +1,427 @@
//! Signal processor for cleaning entry/exit signals.
//!
//! Ensures proper alternation between entries and exits to prevent
//! overlapping positions or orphaned signals.
use crate::core::types::Direction;
/// Signal processor for cleaning raw entry/exit signals.
#[derive(Debug, Clone)]
pub struct SignalProcessor {
/// Whether to allow multiple entries before an exit (pyramiding).
pub allow_pyramiding: bool,
/// Maximum number of pyramid entries.
pub max_pyramid_entries: usize,
}
impl Default for SignalProcessor {
fn default() -> Self {
Self { allow_pyramiding: false, max_pyramid_entries: 1 }
}
}
impl SignalProcessor {
/// Create a new signal processor.
pub fn new() -> Self {
Self::default()
}
/// Enable pyramiding with a maximum number of entries.
pub fn with_pyramiding(mut self, max_entries: usize) -> Self {
self.allow_pyramiding = max_entries > 1;
self.max_pyramid_entries = max_entries;
self
}
/// Clean entry/exit signals to ensure proper alternation.
///
/// Rules:
/// 1. First signal must be an entry
/// 2. After an entry, ignore further entries (unless pyramiding)
/// 3. After an exit, ignore further exits
/// 4. Entries and exits must alternate properly
/// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar
/// when in position, entry takes priority — stay in position (ignore the exit).
///
/// # Arguments
/// * `entries` - Raw entry signals
/// * `exits` - Raw exit signals
///
/// # Returns
/// Tuple of (cleaned_entries, cleaned_exits)
pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec<bool>, Vec<bool>) {
let n = entries.len();
assert_eq!(n, exits.len(), "Entry and exit arrays must have same length");
let mut clean_entries = vec![false; n];
let mut clean_exits = vec![false; n];
if n == 0 {
return (clean_entries, clean_exits);
}
let mut in_position = false;
let mut position_count = 0;
for i in 0..n {
if !in_position {
// Not in position - looking for entry
if entries[i] {
clean_entries[i] = true;
in_position = true;
position_count = 1;
}
// Ignore exits when not in position
} else {
// In position - looking for exit (or pyramid entry)
// Same-bar conflict: entry takes priority — stay in position
if exits[i] && !entries[i] {
// Only exit if there's no conflicting entry signal
clean_exits[i] = true;
if self.allow_pyramiding {
position_count -= 1;
if position_count == 0 {
in_position = false;
}
} else {
in_position = false;
position_count = 0;
}
} else if entries[i]
&& self.allow_pyramiding
&& position_count < self.max_pyramid_entries
{
// Pyramid entry
clean_entries[i] = true;
position_count += 1;
}
// If both entry and exit are True, we stay in position (ignore both)
// If only entry is True and not pyramiding, ignore entry (already in position)
}
}
(clean_entries, clean_exits)
}
/// Clean signals with direction awareness (for strategies that can go long/short).
///
/// # Arguments
/// * `long_entries` - Long entry signals
/// * `long_exits` - Long exit signals
/// * `short_entries` - Short entry signals
/// * `short_exits` - Short exit signals
///
/// # Returns
/// Tuple of (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
pub fn clean_signals_bidirectional(
&self,
long_entries: &[bool],
long_exits: &[bool],
short_entries: &[bool],
short_exits: &[bool],
) -> (Vec<bool>, Vec<bool>, Vec<bool>, Vec<bool>) {
let n = long_entries.len();
assert_eq!(n, long_exits.len());
assert_eq!(n, short_entries.len());
assert_eq!(n, short_exits.len());
let mut clean_long_entries = vec![false; n];
let mut clean_long_exits = vec![false; n];
let mut clean_short_entries = vec![false; n];
let mut clean_short_exits = vec![false; n];
if n == 0 {
return (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits);
}
let mut current_direction: Option<Direction> = None;
for i in 0..n {
match current_direction {
None => {
// Not in any position - look for entry
if long_entries[i] {
clean_long_entries[i] = true;
current_direction = Some(Direction::Long);
} else if short_entries[i] {
clean_short_entries[i] = true;
current_direction = Some(Direction::Short);
}
}
Some(Direction::Long) => {
// In long position - look for exit or reversal
if long_exits[i] {
clean_long_exits[i] = true;
current_direction = None;
} else if short_entries[i] {
// Reversal: exit long and enter short
clean_long_exits[i] = true;
clean_short_entries[i] = true;
current_direction = Some(Direction::Short);
}
}
Some(Direction::Short) => {
// In short position - look for exit or reversal
if short_exits[i] {
clean_short_exits[i] = true;
current_direction = None;
} else if long_entries[i] {
// Reversal: exit short and enter long
clean_short_exits[i] = true;
clean_long_entries[i] = true;
current_direction = Some(Direction::Long);
}
}
}
}
(clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
}
/// Generate exit-on-opposite-entry signals.
///
/// Useful for strategies where an entry in opposite direction
/// should automatically close the current position.
///
/// # Arguments
/// * `entries` - Entry signals
/// * `direction` - Current position direction
///
/// # Returns
/// Modified exit signals that include opposite-direction entries as exits
pub fn exits_from_opposite_entries(
&self,
long_entries: &[bool],
short_entries: &[bool],
) -> (Vec<bool>, Vec<bool>) {
let n = long_entries.len();
assert_eq!(n, short_entries.len());
// Long exits when short entry
// Short exits when long entry
(short_entries.to_vec(), long_entries.to_vec())
}
/// Count the number of trades that would be generated from signals.
///
/// # Arguments
/// * `entries` - Entry signals (already cleaned)
/// * `exits` - Exit signals (already cleaned)
///
/// # Returns
/// Number of complete trades (entry + exit pairs)
pub fn count_trades(_entries: &[bool], exits: &[bool]) -> usize {
exits.iter().filter(|&&e| e).count()
}
/// Get indices of entries and exits.
///
/// # Arguments
/// * `entries` - Entry signals
/// * `exits` - Exit signals
///
/// # Returns
/// Tuple of (entry_indices, exit_indices)
pub fn get_trade_indices(entries: &[bool], exits: &[bool]) -> (Vec<usize>, Vec<usize>) {
let entry_indices: Vec<usize> = entries
.iter()
.enumerate()
.filter_map(|(i, &e)| if e { Some(i) } else { None })
.collect();
let exit_indices: Vec<usize> =
exits.iter().enumerate().filter_map(|(i, &e)| if e { Some(i) } else { None }).collect();
(entry_indices, exit_indices)
}
}
/// Shift signals forward by n bars (delays execution).
pub fn shift_signals(signals: &[bool], n: usize) -> Vec<bool> {
let len = signals.len();
let mut result = vec![false; len];
if n >= len {
return result;
}
for i in n..len {
result[i] = signals[i - n];
}
result
}
/// Combine multiple signal arrays with AND logic.
pub fn combine_signals_and(signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let mut result = vec![true; n];
for sig in signals.iter() {
for i in 0..n {
result[i] = result[i] && sig[i];
}
}
result
}
/// Combine multiple signal arrays with OR logic.
pub fn combine_signals_or(signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let mut result = vec![false; n];
for sig in signals.iter() {
for i in 0..n {
result[i] = result[i] || sig[i];
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_signals_basic() {
let processor = SignalProcessor::new();
let entries = vec![true, false, true, false, true, false];
let exits = vec![false, true, false, true, false, true];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// First entry should be kept
assert!(clean_e[0]);
// First exit should be kept
assert!(clean_x[1]);
// Second entry should be kept
assert!(clean_e[2]);
// Second exit should be kept
assert!(clean_x[3]);
}
#[test]
fn test_clean_signals_consecutive_entries() {
let processor = SignalProcessor::new();
let entries = vec![true, true, true, false, false];
let exits = vec![false, false, false, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Only first entry should be kept
assert!(clean_e[0]);
assert!(!clean_e[1]);
assert!(!clean_e[2]);
// Exit should be kept
assert!(clean_x[3]);
}
#[test]
fn test_clean_signals_consecutive_exits() {
let processor = SignalProcessor::new();
let entries = vec![true, false, false, false, false];
let exits = vec![false, true, true, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Entry should be kept
assert!(clean_e[0]);
// Only first exit should be kept
assert!(clean_x[1]);
assert!(!clean_x[2]);
assert!(!clean_x[3]);
}
#[test]
fn test_clean_signals_exit_before_entry() {
let processor = SignalProcessor::new();
let entries = vec![false, false, true, false, false];
let exits = vec![true, true, false, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Exits before first entry should be ignored
assert!(!clean_x[0]);
assert!(!clean_x[1]);
// Entry should be kept
assert!(clean_e[2]);
// Exit after entry should be kept
assert!(clean_x[3]);
}
#[test]
fn test_pyramiding() {
let processor = SignalProcessor::new().with_pyramiding(3);
let entries = vec![true, true, true, false, false];
let exits = vec![false, false, false, true, true];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// All three entries should be kept (pyramiding)
assert!(clean_e[0]);
assert!(clean_e[1]);
assert!(clean_e[2]);
// Both exits should be kept
assert!(clean_x[3]);
assert!(clean_x[4]);
}
#[test]
fn test_shift_signals() {
let signals = vec![true, false, true, false, true];
let shifted = shift_signals(&signals, 2);
assert!(!shifted[0]);
assert!(!shifted[1]);
assert!(shifted[2]); // Original [0]
assert!(!shifted[3]); // Original [1]
assert!(shifted[4]); // Original [2]
}
#[test]
fn test_combine_signals_and() {
let sig1 = vec![true, true, false, false];
let sig2 = vec![true, false, true, false];
let combined = combine_signals_and(&[&sig1, &sig2]);
assert!(combined[0]); // true && true
assert!(!combined[1]); // true && false
assert!(!combined[2]); // false && true
assert!(!combined[3]); // false && false
}
#[test]
fn test_combine_signals_or() {
let sig1 = vec![true, true, false, false];
let sig2 = vec![true, false, true, false];
let combined = combine_signals_or(&[&sig1, &sig2]);
assert!(combined[0]); // true || true
assert!(combined[1]); // true || false
assert!(combined[2]); // false || true
assert!(!combined[3]); // false || false
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Signal synchronization for multi-instrument strategies.
//!
//! Handles combining signals from multiple instruments with different sync modes.
use crate::core::types::CompiledSignals;
/// Synchronization mode for combining signals from multiple instruments.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncMode {
/// All instruments must signal (AND logic).
All,
/// Any instrument can signal (OR logic).
Any,
/// Majority of instruments must signal.
Majority,
/// Use first instrument's signals as master.
Master,
}
impl Default for SyncMode {
fn default() -> Self {
SyncMode::All
}
}
/// Signal synchronizer for multi-instrument backtests.
#[derive(Debug, Clone)]
pub struct SignalSynchronizer {
/// Synchronization mode.
pub mode: SyncMode,
/// Minimum number of instruments that must signal (for custom thresholds).
pub min_signals: Option<usize>,
}
impl Default for SignalSynchronizer {
fn default() -> Self {
Self { mode: SyncMode::All, min_signals: None }
}
}
impl SignalSynchronizer {
/// Create a new signal synchronizer with the given mode.
pub fn new(mode: SyncMode) -> Self {
Self { mode, min_signals: None }
}
/// Create a synchronizer with a custom minimum signal threshold.
pub fn with_min_signals(min: usize) -> Self {
Self { mode: SyncMode::Majority, min_signals: Some(min) }
}
/// Synchronize entry signals from multiple instruments.
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Combined entry signals based on sync mode
pub fn sync_entries(&self, signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let num_instruments = signals.len();
let mut result = vec![false; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count();
result[i] = match self.mode {
SyncMode::All => count == num_instruments,
SyncMode::Any => count > 0,
SyncMode::Majority => {
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
count >= threshold
}
SyncMode::Master => signals[0][i],
};
}
result
}
/// Synchronize exit signals from multiple instruments.
///
/// Exit logic is typically inverse of entry:
/// - All mode -> exit on Any
/// - Any mode -> exit on All
/// - Majority mode -> exit when majority want to exit
/// - Master mode -> use master's exit signals
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Combined exit signals based on sync mode
pub fn sync_exits(&self, signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let num_instruments = signals.len();
let mut result = vec![false; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count();
result[i] = match self.mode {
// For All entry mode, exit when ANY wants to exit
SyncMode::All => count > 0,
// For Any entry mode, exit when ALL want to exit
SyncMode::Any => count == num_instruments,
SyncMode::Majority => {
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
count >= threshold
}
SyncMode::Master => signals[0][i],
};
}
result
}
/// Synchronize signals from CompiledSignals objects.
///
/// # Arguments
/// * `compiled_signals` - Slice of CompiledSignals from each instrument
///
/// # Returns
/// Tuple of (synchronized_entries, synchronized_exits)
pub fn sync_compiled_signals(
&self,
compiled_signals: &[&CompiledSignals],
) -> (Vec<bool>, Vec<bool>) {
if compiled_signals.is_empty() {
return (vec![], vec![]);
}
let entries: Vec<&[bool]> =
compiled_signals.iter().map(|cs| cs.entries.as_slice()).collect();
let exits: Vec<&[bool]> = compiled_signals.iter().map(|cs| cs.exits.as_slice()).collect();
let synced_entries = self.sync_entries(&entries);
let synced_exits = self.sync_exits(&exits);
(synced_entries, synced_exits)
}
/// Calculate signal agreement score (0.0 to 1.0).
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Vector of agreement scores for each bar
pub fn signal_agreement(&self, signals: &[&[bool]]) -> Vec<f64> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
let num_instruments = signals.len() as f64;
let mut result = vec![0.0; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count() as f64;
result[i] = count / num_instruments;
}
result
}
}
/// Align signals to a common time axis.
///
/// Useful when instruments have different trading hours or missing data.
///
/// # Arguments
/// * `signals` - Signal array to align
/// * `source_timestamps` - Timestamps of the signal array
/// * `target_timestamps` - Target timestamp grid
/// * `fill_value` - Value to use for missing timestamps
///
/// # Returns
/// Aligned signal array
pub fn align_signals(
signals: &[bool],
source_timestamps: &[i64],
target_timestamps: &[i64],
fill_value: bool,
) -> Vec<bool> {
let n = target_timestamps.len();
let mut result = vec![fill_value; n];
// Create a map of source timestamps to indices
let mut source_map = std::collections::HashMap::new();
for (i, &ts) in source_timestamps.iter().enumerate() {
source_map.insert(ts, i);
}
// Fill in values where timestamps match
for (i, &ts) in target_timestamps.iter().enumerate() {
if let Some(&source_idx) = source_map.get(&ts) {
result[i] = signals[source_idx];
}
}
result
}
/// Forward-fill signals (carry forward last signal).
pub fn forward_fill_signals(signals: &[bool]) -> Vec<bool> {
let mut result = signals.to_vec();
let mut last_value = false;
for i in 0..result.len() {
if result[i] {
last_value = true;
}
result[i] = last_value;
}
result
}
/// Create synchronized position signals.
///
/// Returns a position signal where:
/// - 1 = in position
/// - 0 = out of position
///
/// # Arguments
/// * `entries` - Entry signals (cleaned)
/// * `exits` - Exit signals (cleaned)
///
/// # Returns
/// Position state array
pub fn position_signals(entries: &[bool], exits: &[bool]) -> Vec<i8> {
let n = entries.len();
assert_eq!(n, exits.len());
let mut result = vec![0i8; n];
let mut in_position = false;
for i in 0..n {
if entries[i] {
in_position = true;
}
if exits[i] {
in_position = false;
}
result[i] = if in_position { 1 } else { 0 };
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_all() {
let sync = SignalSynchronizer::new(SyncMode::All);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![true, true, false, true];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // All true
assert!(!result[1]); // Not all true
assert!(!result[2]); // All false
assert!(result[3]); // All true
}
#[test]
fn test_sync_any() {
let sync = SignalSynchronizer::new(SyncMode::Any);
let sig1 = vec![true, false, false, false];
let sig2 = vec![false, true, false, false];
let sig3 = vec![false, false, false, false];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // At least one true
assert!(result[1]); // At least one true
assert!(!result[2]); // All false
assert!(!result[3]); // All false
}
#[test]
fn test_sync_majority() {
let sync = SignalSynchronizer::new(SyncMode::Majority);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![false, true, false, false];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // 2 out of 3
assert!(result[1]); // 2 out of 3
assert!(!result[2]); // 0 out of 3
assert!(result[3]); // 2 out of 3
}
#[test]
fn test_sync_master() {
let sync = SignalSynchronizer::new(SyncMode::Master);
let sig1 = vec![true, false, true, false]; // Master
let sig2 = vec![false, true, false, true];
let sig3 = vec![true, true, true, true];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
// Should follow master (sig1)
assert!(result[0]);
assert!(!result[1]);
assert!(result[2]);
assert!(!result[3]);
}
#[test]
fn test_exit_inverse_logic() {
// For All entry mode, exit should be Any
let sync = SignalSynchronizer::new(SyncMode::All);
let exit1 = vec![true, false, false];
let exit2 = vec![false, false, false];
let exit3 = vec![false, false, false];
let result = sync.sync_exits(&[&exit1, &exit2, &exit3]);
assert!(result[0]); // Any true -> exit
assert!(!result[1]);
assert!(!result[2]);
}
#[test]
fn test_signal_agreement() {
let sync = SignalSynchronizer::new(SyncMode::All);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![false, true, false, true];
let result = sync.signal_agreement(&[&sig1, &sig2, &sig3]);
assert!((result[0] - 2.0 / 3.0).abs() < 1e-10);
assert!((result[1] - 2.0 / 3.0).abs() < 1e-10);
assert!((result[2] - 0.0).abs() < 1e-10);
assert!((result[3] - 1.0).abs() < 1e-10);
}
#[test]
fn test_position_signals() {
let entries = vec![false, true, false, false, true, false];
let exits = vec![false, false, false, true, false, true];
let result = position_signals(&entries, &exits);
assert_eq!(result[0], 0);
assert_eq!(result[1], 1);
assert_eq!(result[2], 1);
assert_eq!(result[3], 0);
assert_eq!(result[4], 1);
assert_eq!(result[5], 0);
}
}
+174
View File
@@ -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]);
}
}
+237
View File
@@ -0,0 +1,237 @@
//! ATR-based stop-loss and take-profit.
use super::{StopCalculator, TargetCalculator};
use crate::core::types::{Direction, Price};
/// ATR-based stop-loss.
#[derive(Debug, Clone)]
pub struct AtrStop {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
}
impl AtrStop {
/// Create a new ATR stop.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr }
}
/// Update ATR value.
pub fn update_atr(&mut self, atr: f64) {
self.atr = atr;
}
}
impl StopCalculator for AtrStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => entry_price - distance,
Direction::Short => entry_price + distance,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// ATR stop doesn't trail by default
current_stop
}
}
/// ATR-based take-profit.
#[derive(Debug, Clone)]
pub struct AtrTarget {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
}
impl AtrTarget {
/// Create a new ATR target.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr }
}
/// Update ATR value.
pub fn update_atr(&mut self, atr: f64) {
self.atr = atr;
}
}
impl TargetCalculator for AtrTarget {
fn calculate_target(
&self,
entry_price: Price,
_stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let target = match direction {
Direction::Long => entry_price + distance,
Direction::Short => entry_price - distance,
};
Some(target)
}
}
/// Chandelier exit (ATR-based trailing stop from high/low).
#[derive(Debug, Clone)]
pub struct ChandelierExit {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
/// Highest high since entry (for long).
pub highest_high: f64,
/// Lowest low since entry (for short).
pub lowest_low: f64,
}
impl ChandelierExit {
/// Create a new Chandelier exit.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr, highest_high: 0.0, lowest_low: f64::MAX }
}
/// Reset for new position.
pub fn reset(&mut self, entry_price: Price) {
self.highest_high = entry_price;
self.lowest_low = entry_price;
}
/// Update with new bar data.
pub fn update(&mut self, high: Price, low: Price, atr: f64) {
if high > self.highest_high {
self.highest_high = high;
}
if low < self.lowest_low {
self.lowest_low = low;
}
self.atr = atr;
}
/// Get current stop level.
pub fn stop_level(&self, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => self.highest_high - distance,
Direction::Short => self.lowest_low + distance,
};
Some(stop)
}
}
impl StopCalculator for ChandelierExit {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => entry_price - distance,
Direction::Short => entry_price + distance,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
if self.atr <= 0.0 {
return current_stop;
}
let distance = self.atr * self.multiplier;
let new_stop = match direction {
Direction::Long => {
let proposed = high - distance;
current_stop.map(|cs| cs.max(proposed)).or(Some(proposed))
}
Direction::Short => {
let proposed = low + distance;
current_stop.map(|cs| cs.min(proposed)).or(Some(proposed))
}
};
new_stop
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atr_stop_long() {
let stop = AtrStop::new(2.0, 5.0);
let result = stop.calculate_stop(100.0, Direction::Long);
// 100 - (2 * 5) = 90
assert!((result.unwrap() - 90.0).abs() < 1e-10);
}
#[test]
fn test_atr_stop_short() {
let stop = AtrStop::new(2.0, 5.0);
let result = stop.calculate_stop(100.0, Direction::Short);
// 100 + (2 * 5) = 110
assert!((result.unwrap() - 110.0).abs() < 1e-10);
}
#[test]
fn test_atr_target() {
let target = AtrTarget::new(3.0, 5.0);
let result = target.calculate_target(100.0, None, Direction::Long);
// 100 + (3 * 5) = 115
assert!((result.unwrap() - 115.0).abs() < 1e-10);
}
#[test]
fn test_chandelier_exit() {
let mut chandelier = ChandelierExit::new(3.0, 2.0);
chandelier.reset(100.0);
// Simulate price movement up
chandelier.update(105.0, 99.0, 2.0);
chandelier.update(110.0, 103.0, 2.0);
// Long stop should trail from highest high
// 110 - (3 * 2) = 104
let stop = chandelier.stop_level(Direction::Long);
assert!((stop.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_atr_zero() {
let stop = AtrStop::new(2.0, 0.0);
let result = stop.calculate_stop(100.0, Direction::Long);
assert!(result.is_none());
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Fixed percentage stop-loss and take-profit.
use super::{StopCalculator, TargetCalculator};
use crate::core::types::{Direction, Price};
/// Fixed percentage stop-loss.
#[derive(Debug, Clone, Copy)]
pub struct FixedStop {
/// Stop percentage (e.g., 0.02 for 2%).
pub percent: f64,
}
impl FixedStop {
/// Create a new fixed stop with given percentage.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs() }
}
/// Create a 1% stop.
pub fn one_percent() -> Self {
Self::new(0.01)
}
/// Create a 2% stop.
pub fn two_percent() -> Self {
Self::new(0.02)
}
/// Create a 5% stop.
pub fn five_percent() -> Self {
Self::new(0.05)
}
}
impl StopCalculator for FixedStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price * (1.0 - self.percent),
Direction::Short => entry_price * (1.0 + self.percent),
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// Fixed stop doesn't update
current_stop
}
}
/// Fixed percentage take-profit.
#[derive(Debug, Clone, Copy)]
pub struct FixedTarget {
/// Target percentage (e.g., 0.04 for 4%).
pub percent: f64,
}
impl FixedTarget {
/// Create a new fixed target with given percentage.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs() }
}
}
impl TargetCalculator for FixedTarget {
fn calculate_target(
&self,
entry_price: Price,
_stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
let target = match direction {
Direction::Long => entry_price * (1.0 + self.percent),
Direction::Short => entry_price * (1.0 - self.percent),
};
Some(target)
}
}
/// Risk-reward based take-profit.
#[derive(Debug, Clone, Copy)]
pub struct RiskRewardTarget {
/// Risk-reward ratio (e.g., 2.0 for 2:1 reward:risk).
pub ratio: f64,
}
impl RiskRewardTarget {
/// Create a new risk-reward target.
pub fn new(ratio: f64) -> Self {
Self { ratio }
}
/// Create a 2:1 target.
pub fn two_to_one() -> Self {
Self::new(2.0)
}
/// Create a 3:1 target.
pub fn three_to_one() -> Self {
Self::new(3.0)
}
}
impl TargetCalculator for RiskRewardTarget {
fn calculate_target(
&self,
entry_price: Price,
stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
let stop = stop_price?;
let risk = (entry_price - stop).abs();
let reward = risk * self.ratio;
let target = match direction {
Direction::Long => entry_price + reward,
Direction::Short => entry_price - reward,
};
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_stop_long() {
let stop = FixedStop::new(0.02);
let result = stop.calculate_stop(100.0, Direction::Long);
assert!((result.unwrap() - 98.0).abs() < 1e-10);
}
#[test]
fn test_fixed_stop_short() {
let stop = FixedStop::new(0.02);
let result = stop.calculate_stop(100.0, Direction::Short);
assert!((result.unwrap() - 102.0).abs() < 1e-10);
}
#[test]
fn test_fixed_target_long() {
let target = FixedTarget::new(0.04);
let result = target.calculate_target(100.0, None, Direction::Long);
assert!((result.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_risk_reward_target() {
let target = RiskRewardTarget::new(2.0);
// Entry at 100, stop at 98 (2% risk), target should be at 104 (4% reward)
let result = target.calculate_target(100.0, Some(98.0), Direction::Long);
assert!((result.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_risk_reward_no_stop() {
let target = RiskRewardTarget::new(2.0);
let result = target.calculate_target(100.0, None, Direction::Long);
assert!(result.is_none());
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Stop-loss and take-profit mechanisms for RaptorBT.
pub mod atr;
pub mod fixed;
pub mod trailing;
pub use atr::AtrStop;
pub use fixed::FixedStop;
pub use trailing::TrailingStop;
use crate::core::types::{Direction, Price};
/// Stop-loss calculator trait.
pub trait StopCalculator {
/// Calculate stop price for a new position.
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price>;
/// Update stop price for trailing stops.
fn update_stop(
&self,
current_stop: Option<Price>,
current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price>;
}
/// Take-profit calculator trait.
pub trait TargetCalculator {
/// Calculate target price for a new position.
fn calculate_target(
&self,
entry_price: Price,
stop_price: Option<Price>,
direction: Direction,
) -> Option<Price>;
}
+395
View File
@@ -0,0 +1,395 @@
//! Trailing stop implementations.
use super::StopCalculator;
use crate::core::types::{Direction, Price};
/// Percentage-based trailing stop.
#[derive(Debug, Clone, Copy)]
pub struct TrailingStop {
/// Trail percentage (e.g., 0.05 for 5%).
pub percent: f64,
/// Activation threshold (optional - start trailing after this profit %).
pub activation_threshold: Option<f64>,
}
impl TrailingStop {
/// Create a new trailing stop.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs(), activation_threshold: None }
}
/// Create with activation threshold.
pub fn with_activation(mut self, threshold: f64) -> Self {
self.activation_threshold = Some(threshold.abs());
self
}
/// Check if trailing should be activated.
#[allow(dead_code)]
fn should_activate(
&self,
entry_price: Price,
current_price: Price,
direction: Direction,
) -> bool {
if let Some(threshold) = self.activation_threshold {
let profit_pct = match direction {
Direction::Long => (current_price - entry_price) / entry_price,
Direction::Short => (entry_price - current_price) / entry_price,
};
profit_pct >= threshold
} else {
true // Always active if no threshold
}
}
}
impl StopCalculator for TrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price * (1.0 - self.percent),
Direction::Short => entry_price * (1.0 + self.percent),
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
match direction {
Direction::Long => {
// Trail below the high
let new_stop = high * (1.0 - self.percent);
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
// Trail above the low
let new_stop = low * (1.0 + self.percent);
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Point-based trailing stop (fixed point distance).
#[derive(Debug, Clone, Copy)]
pub struct PointTrailingStop {
/// Trail distance in points.
pub points: f64,
}
impl PointTrailingStop {
/// Create a new point-based trailing stop.
pub fn new(points: f64) -> Self {
Self { points: points.abs() }
}
}
impl StopCalculator for PointTrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price - self.points,
Direction::Short => entry_price + self.points,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
match direction {
Direction::Long => {
let new_stop = high - self.points;
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
let new_stop = low + self.points;
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Step trailing stop (moves in discrete steps).
#[derive(Debug, Clone, Copy)]
pub struct StepTrailingStop {
/// Step size percentage.
pub step_percent: f64,
/// Trail percentage from each step.
pub trail_percent: f64,
}
impl StepTrailingStop {
/// Create a new step trailing stop.
pub fn new(step_percent: f64, trail_percent: f64) -> Self {
Self { step_percent: step_percent.abs(), trail_percent: trail_percent.abs() }
}
/// Calculate stop for a given step level.
fn stop_for_step(&self, entry_price: Price, step: usize, direction: Direction) -> Price {
let step_gain = self.step_percent * step as f64;
match direction {
Direction::Long => {
let step_price = entry_price * (1.0 + step_gain);
step_price * (1.0 - self.trail_percent)
}
Direction::Short => {
let step_price = entry_price * (1.0 - step_gain);
step_price * (1.0 + self.trail_percent)
}
}
}
/// Determine current step level.
#[allow(dead_code)]
fn current_step(
&self,
entry_price: Price,
extreme_price: Price,
direction: Direction,
) -> usize {
let gain = match direction {
Direction::Long => (extreme_price - entry_price) / entry_price,
Direction::Short => (entry_price - extreme_price) / entry_price,
};
if gain <= 0.0 {
return 0;
}
(gain / self.step_percent).floor() as usize
}
}
impl StopCalculator for StepTrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
Some(self.stop_for_step(entry_price, 0, direction))
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
// This is a simplified version - full implementation would need entry price
// For now, just use regular trailing behavior
match direction {
Direction::Long => {
let new_stop = high * (1.0 - self.trail_percent);
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
let new_stop = low * (1.0 + self.trail_percent);
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Parabolic SAR style trailing stop.
#[derive(Debug, Clone)]
pub struct ParabolicStop {
/// Initial acceleration factor.
pub af_start: f64,
/// Acceleration factor increment.
pub af_step: f64,
/// Maximum acceleration factor.
pub af_max: f64,
/// Current acceleration factor.
current_af: f64,
/// Current extreme point.
extreme_point: f64,
/// Current SAR value.
current_sar: f64,
}
impl ParabolicStop {
/// Create a new Parabolic SAR stop with default parameters.
pub fn new() -> Self {
Self::with_params(0.02, 0.02, 0.2)
}
/// Create with custom parameters.
pub fn with_params(af_start: f64, af_step: f64, af_max: f64) -> Self {
Self {
af_start,
af_step,
af_max,
current_af: af_start,
extreme_point: 0.0,
current_sar: 0.0,
}
}
/// Initialize for new position.
pub fn init(&mut self, entry_price: Price, direction: Direction) {
self.current_af = self.af_start;
self.extreme_point = entry_price;
self.current_sar = match direction {
Direction::Long => entry_price * 0.99, // Slightly below entry
Direction::Short => entry_price * 1.01, // Slightly above entry
};
}
/// Update SAR with new bar data.
pub fn update_sar(&mut self, high: Price, low: Price, direction: Direction) -> Price {
// Update extreme point
let new_ep = match direction {
Direction::Long => {
if high > self.extreme_point {
self.current_af = (self.current_af + self.af_step).min(self.af_max);
high
} else {
self.extreme_point
}
}
Direction::Short => {
if low < self.extreme_point {
self.current_af = (self.current_af + self.af_step).min(self.af_max);
low
} else {
self.extreme_point
}
}
};
self.extreme_point = new_ep;
// Calculate new SAR
let new_sar = self.current_sar + self.current_af * (self.extreme_point - self.current_sar);
// Ensure SAR doesn't cross price
self.current_sar = match direction {
Direction::Long => new_sar.min(low),
Direction::Short => new_sar.max(high),
};
self.current_sar
}
}
impl Default for ParabolicStop {
fn default() -> Self {
Self::new()
}
}
impl StopCalculator for ParabolicStop {
fn calculate_stop(&self, _entry_price: Price, _direction: Direction) -> Option<Price> {
if self.current_sar > 0.0 {
Some(self.current_sar)
} else {
None
}
}
fn update_stop(
&self,
_current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// Parabolic stop is updated via update_sar method
if self.current_sar > 0.0 {
Some(self.current_sar)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trailing_stop_long() {
let stop = TrailingStop::new(0.05);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Long);
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
// Update with higher high
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
// 110 * 0.95 = 104.5
assert!((updated.unwrap() - 104.5).abs() < 1e-10);
}
#[test]
fn test_trailing_stop_short() {
let stop = TrailingStop::new(0.05);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Short);
assert!((initial.unwrap() - 105.0).abs() < 1e-10);
// Update with lower low
let updated = stop.update_stop(initial, 92.0, 95.0, 90.0, Direction::Short);
// 90 * 1.05 = 94.5
assert!((updated.unwrap() - 94.5).abs() < 1e-10);
}
#[test]
fn test_trailing_stop_only_tightens() {
let stop = TrailingStop::new(0.05);
let initial = stop.calculate_stop(100.0, Direction::Long);
// Move up
let moved_up = stop.update_stop(initial, 110.0, 110.0, 108.0, Direction::Long);
// 110 * 0.95 = 104.5
assert!((moved_up.unwrap() - 104.5).abs() < 1e-10);
// Move down - stop should NOT move down
let moved_down = stop.update_stop(moved_up, 105.0, 106.0, 103.0, Direction::Long);
// Should still be 104.5 (not 106 * 0.95 = 100.7)
assert!((moved_down.unwrap() - 104.5).abs() < 1e-10);
}
#[test]
fn test_point_trailing_stop() {
let stop = PointTrailingStop::new(5.0);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Long);
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
// Update with higher high
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
// 110 - 5 = 105
assert!((updated.unwrap() - 105.0).abs() < 1e-10);
}
#[test]
fn test_parabolic_stop() {
let mut stop = ParabolicStop::new();
stop.init(100.0, Direction::Long);
// Simulate uptrend
let sar1 = stop.update_sar(102.0, 99.0, Direction::Long);
let sar2 = stop.update_sar(105.0, 101.0, Direction::Long);
let sar3 = stop.update_sar(108.0, 103.0, Direction::Long);
// SAR should be increasing
assert!(sar2 > sar1);
assert!(sar3 > sar2);
// SAR should be below current low
assert!(sar3 < 103.0);
}
}
+505
View File
@@ -0,0 +1,505 @@
//! Basket/collective strategy backtest implementation.
//!
//! Supports multiple instruments with synchronized signals.
use std::collections::HashMap;
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, InstrumentConfig,
OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
use crate::portfolio::allocation::{AllocationStrategy, CapitalAllocator};
use crate::signals::processor::SignalProcessor;
use crate::signals::synchronizer::{SignalSynchronizer, SyncMode};
/// Basket backtest configuration.
#[derive(Debug, Clone)]
pub struct BasketConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Signal synchronization mode.
pub sync_mode: SyncMode,
/// Capital allocation strategy.
pub allocation: AllocationStrategy,
/// Whether to rebalance on each signal.
pub rebalance_on_signal: bool,
}
impl Default for BasketConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
sync_mode: SyncMode::All,
allocation: AllocationStrategy::EqualWeight,
rebalance_on_signal: false,
}
}
}
/// Basket/collective strategy backtest runner.
#[derive(Debug)]
pub struct BasketBacktest {
/// Configuration.
config: BasketConfig,
/// Signal synchronizer.
synchronizer: SignalSynchronizer,
/// Capital allocator.
#[allow(dead_code)]
allocator: CapitalAllocator,
/// Signal processor.
signal_processor: SignalProcessor,
/// Fee model.
fee_model: FeeModel,
}
impl BasketBacktest {
/// Create a new basket backtest.
pub fn new(config: BasketConfig) -> Self {
let allocator = CapitalAllocator::new(config.base.initial_capital)
.with_strategy(config.allocation.clone());
Self {
synchronizer: SignalSynchronizer::new(config.sync_mode),
allocator,
signal_processor: SignalProcessor::new(),
fee_model: FeeModel::percentage(config.base.fees),
config,
}
}
/// Run basket backtest with multiple instruments.
///
/// # Arguments
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
///
/// # Returns
/// Combined backtest result
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
self.run_with_instrument_configs(instruments, None)
}
/// Run basket backtest with optional per-instrument configurations.
///
/// # Arguments
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
/// * `instrument_configs` - Optional map of symbol -> InstrumentConfig
///
/// # Returns
/// Combined backtest result
pub fn run_with_instrument_configs(
&self,
instruments: &[(OhlcvData, CompiledSignals)],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> BacktestResult {
if instruments.is_empty() {
return self.empty_result();
}
let n_instruments = instruments.len();
let n_bars = instruments[0].0.len();
// Verify all instruments have same length
for (ohlcv, signals) in instruments {
assert_eq!(ohlcv.len(), n_bars, "All instruments must have same number of bars");
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
}
// Synchronize signals
let entry_signals: Vec<&[bool]> =
instruments.iter().map(|(_, s)| s.entries.as_slice()).collect();
let exit_signals: Vec<&[bool]> =
instruments.iter().map(|(_, s)| s.exits.as_slice()).collect();
let synced_entries = self.synchronizer.sync_entries(&entry_signals);
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
// Clean signals
let (clean_entries, clean_exits) =
self.signal_processor.clean_signals(&synced_entries, &synced_exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut positions: Vec<Option<PositionState>> = vec![None; n_instruments];
let mut equity_curve = vec![cash; n_bars];
let mut drawdown_curve = vec![0.0; n_bars];
let mut returns = vec![0.0; n_bars];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n_bars {
// Calculate current position values
let mut _total_position_value = 0.0;
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
if let Some(ref pos) = positions[inst_idx] {
_total_position_value += pos.size * ohlcv.close[i];
}
}
// Check for exit
if clean_exits[i] {
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
if let Some(pos) = positions[inst_idx].take() {
let exit_price = ohlcv.close[i];
let fees =
self.fee_model.calculate(exit_price, pos.size, signals.direction);
let pnl = (exit_price - pos.entry_price)
* pos.size
* signals.direction.multiplier()
- fees;
let cost_basis = pos.entry_price * pos.size;
let return_pct =
if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
cash += exit_price * pos.size - fees;
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: signals.direction,
pnl,
return_pct,
entry_time: ohlcv.timestamps[pos.entry_idx],
exit_time: ohlcv.timestamps[i],
fees,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
}
// Check for entry
if clean_entries[i] && positions.iter().all(|p| p.is_none()) {
// Calculate position sizes
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect();
let symbols: Vec<&str> =
instruments.iter().map(|(_, s)| s.symbol.as_str()).collect();
let sizes = self.calculate_sizes_with_configs(
&prices,
&weights,
cash,
&symbols,
instrument_configs,
);
// Enter positions
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
let size = sizes[inst_idx];
if size > 0.0 {
let entry_price = ohlcv.close[i];
let fees = self.fee_model.calculate(entry_price, size, signals.direction);
cash -= entry_price * size + fees;
positions[inst_idx] =
Some(PositionState { entry_idx: i, entry_price, size });
}
}
}
// Update equity
let mut position_value = 0.0;
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
if let Some(ref pos) = positions[inst_idx] {
position_value += pos.size * ohlcv.close[i];
}
}
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining positions
let last_idx = n_bars - 1;
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
if let Some(pos) = positions[inst_idx].take() {
let exit_price = ohlcv.close[last_idx];
let fees = self.fee_model.calculate(exit_price, pos.size, signals.direction);
let pnl =
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
- fees;
let cost_basis = pos.entry_price * pos.size;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: signals.direction,
pnl,
return_pct,
entry_time: ohlcv.timestamps[pos.entry_idx],
exit_time: ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Calculate position sizes for each instrument.
#[allow(dead_code)]
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
let symbols: Vec<&str> = vec![""; prices.len()];
self.calculate_sizes_with_configs(prices, weights, available_capital, &symbols, None)
}
/// Calculate position sizes with optional per-instrument config (lot_size rounding, capital caps).
fn calculate_sizes_with_configs(
&self,
prices: &[f64],
weights: &[f64],
available_capital: f64,
symbols: &[&str],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> Vec<f64> {
let n = prices.len();
let total_weight: f64 = weights.iter().sum();
if total_weight == 0.0 {
return vec![0.0; n];
}
prices
.iter()
.zip(weights.iter())
.enumerate()
.map(|(idx, (&price, &weight))| {
if price <= 0.0 {
return 0.0;
}
let default_allocation = available_capital * (weight / total_weight);
// Use per-instrument alloted_capital if set, capped at default allocation
let inst_config = instrument_configs
.and_then(|configs| symbols.get(idx).and_then(|sym| configs.get(*sym)));
let allocation = inst_config
.and_then(|ic| ic.alloted_capital)
.map(|cap| cap.min(default_allocation))
.unwrap_or(default_allocation);
let raw_size = allocation / price;
// Round to lot_size
inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size)
})
.collect()
}
/// Calculate metrics for the backtest.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
let sharpe_ratio = streaming.sharpe_ratio(252.0);
let sortino_ratio = streaming.sortino_ratio(252.0);
let calmar_ratio = if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
/// Create empty result.
fn empty_result(&self) -> BacktestResult {
BacktestResult::new(
BacktestMetrics {
start_value: self.config.base.initial_capital,
end_value: self.config.base.initial_capital,
..Default::default()
},
vec![],
vec![],
vec![],
vec![],
)
}
}
/// Internal position state.
#[derive(Debug, Clone)]
struct PositionState {
entry_idx: usize,
entry_price: f64,
size: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Direction;
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
let n = 20;
let ohlcv1 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
let ohlcv2 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (50..50 + n).map(|x| x as f64).collect(),
high: (51..51 + n).map(|x| x as f64).collect(),
low: (49..49 + n).map(|x| x as f64).collect(),
close: (50..50 + n).map(|x| x as f64 + 0.25).collect(),
volume: vec![2000.0; n],
};
let mut entries1 = vec![false; n];
let mut exits1 = vec![false; n];
entries1[2] = true;
exits1[8] = true;
let mut entries2 = vec![false; n];
let mut exits2 = vec![false; n];
entries2[2] = true;
exits2[8] = true;
let signals1 = CompiledSignals {
symbol: "INST1".to_string(),
entries: entries1,
exits: exits1,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let signals2 = CompiledSignals {
symbol: "INST2".to_string(),
entries: entries2,
exits: exits2,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
vec![(ohlcv1, signals1), (ohlcv2, signals2)]
}
#[test]
fn test_basket_backtest() {
let config = BasketConfig::default();
let backtest = BasketBacktest::new(config);
let instruments = sample_instruments();
let result = backtest.run(&instruments);
// Should have trades for both instruments
assert!(result.trades.len() >= 2);
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_sync_mode_all() {
let config = BasketConfig { sync_mode: SyncMode::All, ..Default::default() };
let backtest = BasketBacktest::new(config);
let instruments = sample_instruments();
let result = backtest.run(&instruments);
// With All mode, both instruments should enter at same time
assert!(result.trades.len() >= 2);
}
#[test]
fn test_empty_instruments() {
let config = BasketConfig::default();
let backtest = BasketBacktest::new(config);
let result = backtest.run(&[]);
assert_eq!(result.trades.len(), 0);
assert!(result.equity_curve.is_empty());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Strategy implementations for different backtest types.
pub mod basket;
pub mod multi;
pub mod options;
pub mod pairs;
pub mod single;
pub mod spreads;
pub mod tick;
pub use basket::BasketBacktest;
pub use multi::MultiStrategyBacktest;
pub use options::OptionsBacktest;
pub use pairs::PairsBacktest;
pub use single::SingleBacktest;
pub use spreads::{
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
};
pub use tick::{TickBacktest, TickBacktestConfig};
+378
View File
@@ -0,0 +1,378 @@
//! Multi-strategy backtest implementation.
//!
//! Supports running multiple strategies on the same instrument.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Strategy combination mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombineMode {
/// Enter when any strategy signals.
Any,
/// Enter when all strategies signal.
All,
/// Enter when majority of strategies signal.
Majority,
/// Run strategies independently with separate capital.
Independent,
/// Vote-weighted combination.
Weighted,
}
impl Default for CombineMode {
fn default() -> Self {
CombineMode::Any
}
}
/// Multi-strategy configuration.
#[derive(Debug, Clone)]
pub struct MultiStrategyConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Strategy combination mode.
pub combine_mode: CombineMode,
/// Capital allocation per strategy (for independent mode).
pub capital_per_strategy: Option<f64>,
/// Strategy weights (for weighted mode).
pub strategy_weights: Vec<f64>,
}
impl Default for MultiStrategyConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
combine_mode: CombineMode::Any,
capital_per_strategy: None,
strategy_weights: vec![],
}
}
}
/// Multi-strategy backtest runner.
#[derive(Debug)]
pub struct MultiStrategyBacktest {
/// Configuration.
config: MultiStrategyConfig,
/// Fee model.
#[allow(dead_code)]
fee_model: FeeModel,
}
impl MultiStrategyBacktest {
/// Create a new multi-strategy backtest.
pub fn new(config: MultiStrategyConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run multi-strategy backtest.
///
/// # Arguments
/// * `ohlcv` - OHLCV data for the instrument
/// * `strategies` - Vector of compiled signals from each strategy
///
/// # Returns
/// Combined backtest result
pub fn run(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
if strategies.is_empty() {
return self.empty_result();
}
let n = ohlcv.len();
for signals in strategies {
assert_eq!(signals.len(), n, "All strategies must have same length as OHLCV");
}
match self.config.combine_mode {
CombineMode::Independent => self.run_independent(ohlcv, strategies),
_ => self.run_combined(ohlcv, strategies),
}
}
/// Run strategies independently with separate capital.
fn run_independent(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
let n_strategies = strategies.len();
let capital_per = self
.config
.capital_per_strategy
.unwrap_or(self.config.base.initial_capital / n_strategies as f64);
// Run each strategy independently
let mut all_trades: Vec<Trade> = Vec::new();
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
for (strat_idx, signals) in strategies.iter().enumerate() {
let single_config =
BacktestConfig { initial_capital: capital_per, ..self.config.base.clone() };
let single = crate::strategies::single::SingleBacktest::new(single_config);
let result = single.run(ohlcv, signals);
// Tag trades with strategy index
for mut trade in result.trades {
trade.symbol = format!("{}_{}", trade.symbol, strat_idx);
all_trades.push(trade);
}
strategy_equities.push(result.equity_curve);
}
// Combine equity curves
let n = ohlcv.len();
let mut combined_equity = vec![0.0; n];
for i in 0..n {
for equity in &strategy_equities {
combined_equity[i] += equity[i];
}
}
// Calculate drawdown
let mut peak = combined_equity[0];
let mut drawdown_curve = vec![0.0; n];
for i in 0..n {
if combined_equity[i] > peak {
peak = combined_equity[i];
}
drawdown_curve[i] = (peak - combined_equity[i]) / peak * 100.0;
}
// Calculate returns
let mut returns = vec![0.0; n];
for i in 1..n {
returns[i] = (combined_equity[i] - combined_equity[i - 1]) / combined_equity[i - 1];
}
// Calculate metrics
let mut streaming = StreamingMetrics::new();
for trade in &all_trades {
streaming.update(trade.return_pct / 100.0);
}
let metrics = self.calculate_metrics(
&combined_equity,
&drawdown_curve,
&all_trades,
&streaming,
self.config.base.initial_capital,
);
BacktestResult::new(metrics, combined_equity, drawdown_curve, all_trades, returns)
}
/// Run strategies with combined signals.
fn run_combined(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
let n = ohlcv.len();
let n_strategies = strategies.len();
// Combine entry signals
let mut combined_entries = vec![false; n];
let mut combined_exits = vec![false; n];
for i in 0..n {
let entry_count = strategies.iter().filter(|s| s.entries[i]).count();
let exit_count = strategies.iter().filter(|s| s.exits[i]).count();
combined_entries[i] = match self.config.combine_mode {
CombineMode::Any => entry_count > 0,
CombineMode::All => entry_count == n_strategies,
CombineMode::Majority => entry_count > n_strategies / 2,
CombineMode::Weighted => {
let weighted_sum: f64 = strategies
.iter()
.enumerate()
.filter(|(_, s)| s.entries[i])
.map(|(idx, _)| {
self.config.strategy_weights.get(idx).copied().unwrap_or(1.0)
})
.sum();
let total_weight: f64 =
self.config.strategy_weights.iter().sum::<f64>().max(n_strategies as f64);
weighted_sum / total_weight > 0.5
}
CombineMode::Independent => unreachable!(),
};
// Exit when any strategy wants to exit (conservative)
combined_exits[i] = exit_count > 0;
}
// Use first strategy's direction and symbol
let direction = strategies[0].direction;
let symbol = strategies[0].symbol.clone();
let combined_signals = CompiledSignals {
symbol,
entries: combined_entries,
exits: combined_exits,
position_sizes: None,
direction,
weight: 1.0,
};
// Run single backtest with combined signals
let single = crate::strategies::single::SingleBacktest::new(self.config.base.clone());
single.run(ohlcv, &combined_signals)
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
initial_capital: f64,
) -> BacktestMetrics {
let start_value = initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
/// Create empty result.
fn empty_result(&self) -> BacktestResult {
BacktestResult::new(
BacktestMetrics {
start_value: self.config.base.initial_capital,
end_value: self.config.base.initial_capital,
..Default::default()
},
vec![],
vec![],
vec![],
vec![],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Direction;
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
let n = 20;
let ohlcv = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
// Strategy 1: Early entry
let mut entries1 = vec![false; n];
let mut exits1 = vec![false; n];
entries1[2] = true;
exits1[8] = true;
// Strategy 2: Later entry
let mut entries2 = vec![false; n];
let mut exits2 = vec![false; n];
entries2[4] = true;
exits2[10] = true;
let signals1 = CompiledSignals {
symbol: "TEST".to_string(),
entries: entries1,
exits: exits1,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let signals2 = CompiledSignals {
symbol: "TEST".to_string(),
entries: entries2,
exits: exits2,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
(ohlcv, vec![signals1, signals2])
}
#[test]
fn test_multi_any_mode() {
let config = MultiStrategyConfig { combine_mode: CombineMode::Any, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With Any mode, should enter at index 2 (first strategy)
assert!(!result.trades.is_empty());
}
#[test]
fn test_multi_all_mode() {
let config = MultiStrategyConfig { combine_mode: CombineMode::All, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With All mode, should not enter (strategies don't signal at same time)
assert!(result.trades.is_empty() || result.trades.len() < 2);
}
#[test]
fn test_multi_independent_mode() {
let config =
MultiStrategyConfig { combine_mode: CombineMode::Independent, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With Independent mode, should have trades from both strategies
assert!(result.trades.len() >= 2);
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Options strategy backtest implementation.
//!
//! Supports dynamic strike selection and options-specific position sizing.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Options position type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionType {
Call,
Put,
}
/// Strike selection mode.
#[derive(Debug, Clone, Copy)]
pub enum StrikeSelection {
/// At-the-money (closest to spot).
Atm,
/// In-the-money by N strikes.
Itm(usize),
/// Out-of-the-money by N strikes.
Otm(usize),
/// Fixed strike offset from ATM in percentage.
PercentOffset(f64),
/// Delta-based selection.
Delta(f64),
}
impl Default for StrikeSelection {
fn default() -> Self {
StrikeSelection::Atm
}
}
/// Position size type for options.
#[derive(Debug, Clone, Copy)]
pub enum SizeType {
/// Fixed number of contracts.
Contracts(usize),
/// Percentage of capital.
Percent(f64),
/// Fixed notional value.
Notional(f64),
/// Risk-based (percentage of capital at risk).
RiskPercent(f64),
}
impl Default for SizeType {
fn default() -> Self {
SizeType::Percent(1.0)
}
}
/// Options backtest configuration.
#[derive(Debug, Clone)]
pub struct OptionsConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Option type (call/put).
pub option_type: OptionType,
/// Strike selection mode.
pub strike_selection: StrikeSelection,
/// Position size type.
pub size_type: SizeType,
/// Lot size (contracts per lot).
pub lot_size: usize,
/// Strike interval.
pub strike_interval: f64,
/// Days to expiry preference.
pub target_dte: Option<usize>,
}
impl Default for OptionsConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
option_type: OptionType::Call,
strike_selection: StrikeSelection::Atm,
size_type: SizeType::Percent(1.0),
lot_size: 1,
strike_interval: 50.0,
target_dte: None,
}
}
}
/// Options backtest runner.
#[derive(Debug)]
pub struct OptionsBacktest {
/// Configuration.
config: OptionsConfig,
/// Fee model.
fee_model: FeeModel,
}
impl OptionsBacktest {
/// Create a new options backtest.
pub fn new(config: OptionsConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run options backtest.
///
/// # Arguments
/// * `spot_ohlcv` - Spot/underlying OHLCV data
/// * `option_prices` - Option premium prices (parallel array)
/// * `signals` - Trading signals
///
/// # Returns
/// Backtest result
pub fn run(
&self,
spot_ohlcv: &OhlcvData,
option_prices: &[f64],
signals: &CompiledSignals,
) -> BacktestResult {
let n = spot_ohlcv.len();
assert_eq!(n, option_prices.len());
assert_eq!(n, signals.len());
// Clean signals
let processor = crate::signals::processor::SignalProcessor::new();
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut position: Option<OptionsPosition> = None;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n {
let spot_price = spot_ohlcv.close[i];
let option_price = option_prices[i];
// Check for exit
if exits[i] {
if let Some(pos) = position.take() {
let exit_price = option_price;
let fees = self.fee_model.calculate(
exit_price,
pos.contracts as f64,
signals.direction,
);
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
let cost_basis =
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.entry_price,
exit_price,
size: pos.contracts as f64,
direction: signals.direction,
pnl,
return_pct,
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
exit_time: spot_ohlcv.timestamps[i],
fees,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Check for entry
if entries[i] && position.is_none() {
let strike = self.select_strike(spot_price);
let contracts = self.calculate_contracts(option_price, cash);
if contracts > 0 {
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
let fees =
self.fee_model.calculate(option_price, contracts as f64, signals.direction);
cash -= entry_cost + fees;
position = Some(OptionsPosition {
entry_idx: i,
entry_price: option_price,
strike,
contracts,
option_type: self.config.option_type,
});
}
}
// Update equity
let position_value = if let Some(ref pos) = position {
option_price * pos.contracts as f64 * self.config.lot_size as f64
} else {
0.0
};
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining position
if let Some(pos) = position.take() {
let last_idx = n - 1;
let exit_price = option_prices[last_idx];
let fees =
self.fee_model.calculate(exit_price, pos.contracts as f64, signals.direction);
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
let cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.contracts as f64,
direction: signals.direction,
pnl,
return_pct,
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
exit_time: spot_ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
streaming.update(return_pct / 100.0);
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Select strike price based on configuration.
fn select_strike(&self, spot_price: f64) -> f64 {
let interval = self.config.strike_interval;
let atm_strike = (spot_price / interval).round() * interval;
match self.config.strike_selection {
StrikeSelection::Atm => atm_strike,
StrikeSelection::Itm(n) => match self.config.option_type {
OptionType::Call => atm_strike - (n as f64 * interval),
OptionType::Put => atm_strike + (n as f64 * interval),
},
StrikeSelection::Otm(n) => match self.config.option_type {
OptionType::Call => atm_strike + (n as f64 * interval),
OptionType::Put => atm_strike - (n as f64 * interval),
},
StrikeSelection::PercentOffset(pct) => {
let offset = spot_price * pct;
match self.config.option_type {
OptionType::Call => atm_strike + offset,
OptionType::Put => atm_strike - offset,
}
}
StrikeSelection::Delta(_) => atm_strike, // Simplified - would need options chain
}
}
/// Calculate number of contracts based on size type.
fn calculate_contracts(&self, option_price: f64, available_capital: f64) -> usize {
if option_price <= 0.0 {
return 0;
}
let contract_cost = option_price * self.config.lot_size as f64;
match self.config.size_type {
SizeType::Contracts(n) => n,
SizeType::Percent(pct) => {
let allocation = available_capital * pct;
(allocation / contract_cost) as usize
}
SizeType::Notional(value) => (value / contract_cost) as usize,
SizeType::RiskPercent(pct) => {
// Max loss is the premium paid
let risk_amount = available_capital * pct;
(risk_amount / contract_cost) as usize
}
}
}
/// Calculate P&L for a position.
fn calculate_pnl(&self, position: &OptionsPosition, current_price: f64) -> f64 {
let multiplier = self.config.lot_size as f64;
(current_price - position.entry_price) * position.contracts as f64 * multiplier
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
}
/// Internal options position state.
#[derive(Debug, Clone)]
struct OptionsPosition {
entry_idx: usize,
entry_price: f64,
#[allow(dead_code)]
strike: f64,
contracts: usize,
#[allow(dead_code)]
option_type: OptionType,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strike_selection_atm() {
let config = OptionsConfig {
strike_interval: 50.0,
strike_selection: StrikeSelection::Atm,
..Default::default()
};
let backtest = OptionsBacktest::new(config);
// Spot at 17834, ATM should be 17850
let strike = backtest.select_strike(17834.0);
assert!((strike - 17850.0).abs() < 1e-10);
}
#[test]
fn test_strike_selection_otm() {
let config = OptionsConfig {
strike_interval: 50.0,
strike_selection: StrikeSelection::Otm(2),
option_type: OptionType::Call,
..Default::default()
};
let backtest = OptionsBacktest::new(config);
// Spot at 17834, ATM=17850, OTM 2 strikes = 17950
let strike = backtest.select_strike(17834.0);
assert!((strike - 17950.0).abs() < 1e-10);
}
#[test]
fn test_position_sizing_percent() {
let config =
OptionsConfig { size_type: SizeType::Percent(0.5), lot_size: 50, ..Default::default() };
let backtest = OptionsBacktest::new(config);
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
let contracts = backtest.calculate_contracts(100.0, 100_000.0);
assert_eq!(contracts, 10);
}
}
+453
View File
@@ -0,0 +1,453 @@
//! Pairs trading strategy backtest implementation.
//!
//! Supports long/short legs with hedge ratios.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Pairs trading configuration.
#[derive(Debug, Clone)]
pub struct PairsConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Hedge ratio (units of leg2 per unit of leg1).
pub hedge_ratio: f64,
/// Whether to dynamically update hedge ratio.
pub dynamic_hedge: bool,
/// Lookback period for dynamic hedge calculation.
pub hedge_lookback: usize,
/// Maximum spread for entry.
pub max_spread: Option<f64>,
/// Entry z-score threshold.
pub entry_zscore: f64,
/// Exit z-score threshold.
pub exit_zscore: f64,
}
impl Default for PairsConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
hedge_ratio: 1.0,
dynamic_hedge: false,
hedge_lookback: 20,
max_spread: None,
entry_zscore: 2.0,
exit_zscore: 0.5,
}
}
}
/// Pairs trading backtest runner.
#[derive(Debug)]
pub struct PairsBacktest {
/// Configuration.
config: PairsConfig,
/// Fee model.
fee_model: FeeModel,
}
impl PairsBacktest {
/// Create a new pairs backtest.
pub fn new(config: PairsConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run pairs trading backtest.
///
/// # Arguments
/// * `leg1_ohlcv` - OHLCV data for leg 1 (long leg when spread widens)
/// * `leg2_ohlcv` - OHLCV data for leg 2 (short leg when spread widens)
/// * `signals` - Entry/exit signals based on spread
///
/// # Returns
/// Backtest result
pub fn run(
&self,
leg1_ohlcv: &OhlcvData,
leg2_ohlcv: &OhlcvData,
signals: &CompiledSignals,
) -> BacktestResult {
let n = leg1_ohlcv.len();
assert_eq!(n, leg2_ohlcv.len());
assert_eq!(n, signals.len());
// Clean signals
let processor = crate::signals::processor::SignalProcessor::new();
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut position: Option<PairsPosition> = None;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n {
let leg1_price = leg1_ohlcv.close[i];
let leg2_price = leg2_ohlcv.close[i];
// Calculate current hedge ratio
let hedge_ratio = if self.config.dynamic_hedge && i >= self.config.hedge_lookback {
self.calculate_hedge_ratio(
&leg1_ohlcv.close[i - self.config.hedge_lookback..=i],
&leg2_ohlcv.close[i - self.config.hedge_lookback..=i],
)
} else {
self.config.hedge_ratio
};
// Check for exit
if exits[i] {
if let Some(pos) = position.take() {
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
let cost_basis = pos.leg1_cost + pos.leg2_cost;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
// Return capital
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
// Record trades for both legs
trades.push(Trade {
id: trade_counter,
symbol: format!("{}_LEG1", signals.symbol),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.leg1_entry_price,
exit_price: leg1_price,
size: pos.leg1_size,
direction: pos.leg1_direction,
pnl: pnl / 2.0, // Split P&L attribution
return_pct: return_pct / 2.0,
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
exit_time: leg1_ohlcv.timestamps[i],
fees: fees / 2.0,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
trades.push(Trade {
id: trade_counter,
symbol: format!("{}_LEG2", signals.symbol),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.leg2_entry_price,
exit_price: leg2_price,
size: pos.leg2_size,
direction: pos.leg2_direction,
pnl: pnl / 2.0,
return_pct: return_pct / 2.0,
entry_time: leg2_ohlcv.timestamps[pos.entry_idx],
exit_time: leg2_ohlcv.timestamps[i],
fees: fees / 2.0,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Check for entry
if entries[i] && position.is_none() {
// Determine direction from signal direction
let (leg1_dir, leg2_dir) = match signals.direction {
Direction::Long => (Direction::Long, Direction::Short),
Direction::Short => (Direction::Short, Direction::Long),
};
// Calculate position sizes
let allocation = cash * 0.5; // Use 50% per leg
let leg1_size = allocation / leg1_price;
let leg2_size = (allocation * hedge_ratio) / leg2_price;
let leg1_cost = leg1_size * leg1_price;
let leg2_cost = leg2_size * leg2_price;
let entry_fees = self.fee_model.calculate(leg1_price, leg1_size, leg1_dir)
+ self.fee_model.calculate(leg2_price, leg2_size, leg2_dir);
cash -= leg1_cost + leg2_cost + entry_fees;
position = Some(PairsPosition {
entry_idx: i,
leg1_entry_price: leg1_price,
leg2_entry_price: leg2_price,
leg1_size,
leg2_size,
leg1_direction: leg1_dir,
leg2_direction: leg2_dir,
leg1_cost,
leg2_cost,
hedge_ratio,
});
}
// Update equity
let position_value = if let Some(ref pos) = position {
let _leg1_value = pos.leg1_size * leg1_price;
let _leg2_value = pos.leg2_size * leg2_price;
// For pairs, value is long leg - short leg + cash equivalent
let leg1_pnl = (leg1_price - pos.leg1_entry_price)
* pos.leg1_size
* pos.leg1_direction.multiplier();
let leg2_pnl = (leg2_price - pos.leg2_entry_price)
* pos.leg2_size
* pos.leg2_direction.multiplier();
pos.leg1_cost + pos.leg2_cost + leg1_pnl + leg2_pnl
} else {
0.0
};
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining position
if let Some(pos) = position.take() {
let last_idx = n - 1;
let leg1_price = leg1_ohlcv.close[last_idx];
let leg2_price = leg2_ohlcv.close[last_idx];
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
let cost_basis = pos.leg1_cost + pos.leg2_cost;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.leg1_entry_price,
exit_price: leg1_price,
size: pos.leg1_size + pos.leg2_size,
direction: pos.leg1_direction,
pnl,
return_pct,
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
exit_time: leg1_ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
streaming.update(return_pct / 100.0);
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Calculate hedge ratio using OLS regression.
fn calculate_hedge_ratio(&self, leg1_prices: &[f64], leg2_prices: &[f64]) -> f64 {
let n = leg1_prices.len() as f64;
if n < 2.0 {
return self.config.hedge_ratio;
}
let sum_x: f64 = leg2_prices.iter().sum();
let sum_y: f64 = leg1_prices.iter().sum();
let sum_xy: f64 = leg1_prices.iter().zip(leg2_prices.iter()).map(|(y, x)| x * y).sum();
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
let denominator = n * sum_x2 - sum_x * sum_x;
if denominator.abs() < 1e-10 {
return self.config.hedge_ratio;
}
let beta = (n * sum_xy - sum_x * sum_y) / denominator;
beta.max(0.1).min(10.0) // Constrain to reasonable range
}
/// Close position and calculate P&L.
fn close_position(
&self,
position: &PairsPosition,
leg1_price: f64,
leg2_price: f64,
) -> (f64, f64) {
let leg1_pnl = (leg1_price - position.leg1_entry_price)
* position.leg1_size
* position.leg1_direction.multiplier();
let leg2_pnl = (leg2_price - position.leg2_entry_price)
* position.leg2_size
* position.leg2_direction.multiplier();
let exit_fees =
self.fee_model.calculate(leg1_price, position.leg1_size, position.leg1_direction)
+ self.fee_model.calculate(leg2_price, position.leg2_size, position.leg2_direction);
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
(total_pnl, exit_fees)
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
// For pairs, count trade pairs (every 2 trades = 1 round trip)
let total_trades = trades.len() / 2;
let winning_trades =
trades.chunks(2).filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0).count();
let losing_trades = total_trades.saturating_sub(winning_trades);
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
}
/// Internal pairs position state.
#[derive(Debug, Clone)]
struct PairsPosition {
entry_idx: usize,
leg1_entry_price: f64,
leg2_entry_price: f64,
leg1_size: f64,
leg2_size: f64,
leg1_direction: Direction,
leg2_direction: Direction,
leg1_cost: f64,
leg2_cost: f64,
#[allow(dead_code)]
hedge_ratio: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_pairs_data() -> (OhlcvData, OhlcvData, CompiledSignals) {
let n = 20;
// Leg 1: Trending up
let leg1 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
// Leg 2: Correlated but with different magnitude
let leg2 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (50..50 + n).map(|x| x as f64).collect(),
high: (51..51 + n).map(|x| x as f64).collect(),
low: (49..49 + n).map(|x| x as f64).collect(),
close: (50..50 + n).map(|x| x as f64 + 0.2).collect(),
volume: vec![2000.0; n],
};
let mut entries = vec![false; n];
let mut exits = vec![false; n];
entries[2] = true;
exits[10] = true;
let signals = CompiledSignals {
symbol: "PAIR".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long, // Long leg1, short leg2
weight: 1.0,
};
(leg1, leg2, signals)
}
#[test]
fn test_pairs_backtest() {
let config = PairsConfig::default();
let backtest = PairsBacktest::new(config);
let (leg1, leg2, signals) = sample_pairs_data();
let result = backtest.run(&leg1, &leg2, &signals);
// Should have trades for both legs
assert!(result.trades.len() >= 2);
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_hedge_ratio_calculation() {
let config = PairsConfig { dynamic_hedge: true, hedge_lookback: 5, ..Default::default() };
let backtest = PairsBacktest::new(config);
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
let leg2 = vec![50.0, 51.0, 52.0, 53.0, 54.0];
let ratio = backtest.calculate_hedge_ratio(&leg1, &leg2);
// Ratio should be approximately 2 (leg1 moves 2x leg2)
assert!(ratio > 1.5 && ratio < 2.5);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Single instrument backtest implementation.
use crate::core::types::{
BacktestConfig, BacktestResult, CompiledSignals, InstrumentConfig, OhlcvData,
};
use crate::portfolio::engine::PortfolioEngine;
/// Single instrument backtest runner.
#[derive(Debug)]
pub struct SingleBacktest {
/// Portfolio engine.
engine: PortfolioEngine,
}
impl SingleBacktest {
/// Create a new single instrument backtest.
pub fn new(config: BacktestConfig) -> Self {
Self { engine: PortfolioEngine::new(config) }
}
/// Run the backtest.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
self.engine.run_single(ohlcv, signals)
}
/// Run the backtest with per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
self.engine.run_single_with_instrument_config(ohlcv, signals, inst_config)
}
/// Run backtest from raw arrays.
///
/// # Arguments
/// * `timestamps` - Timestamp array
/// * `open` - Open prices
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume
/// * `entries` - Entry signals
/// * `exits` - Exit signals
/// * `direction` - Trade direction (1 = long, -1 = short)
/// * `symbol` - Symbol name
///
/// # Returns
/// Backtest result
pub fn run_from_arrays(
&self,
timestamps: &[i64],
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
entries: &[bool],
exits: &[bool],
direction: i32,
symbol: &str,
) -> BacktestResult {
let ohlcv = OhlcvData {
timestamps: timestamps.to_vec(),
open: open.to_vec(),
high: high.to_vec(),
low: low.to_vec(),
close: close.to_vec(),
volume: volume.to_vec(),
};
let dir = crate::core::types::Direction::from_int(direction)
.unwrap_or(crate::core::types::Direction::Long);
let signals = CompiledSignals {
symbol: symbol.to_string(),
entries: entries.to_vec(),
exits: exits.to_vec(),
position_sizes: None,
direction: dir,
weight: 1.0,
};
self.run(&ohlcv, &signals)
}
/// Run backtest with position sizing.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
/// * `position_sizes` - Position size for each bar (fraction of capital)
///
/// # Returns
/// Backtest result
pub fn run_with_sizing(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
position_sizes: Vec<f64>,
) -> BacktestResult {
let mut signals_with_sizing = signals.clone();
signals_with_sizing.position_sizes = Some(position_sizes);
self.engine.run_single(ohlcv, &signals_with_sizing)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{Direction, StopConfig, TargetConfig};
fn sample_data() -> (OhlcvData, CompiledSignals) {
let ohlcv = OhlcvData {
timestamps: (0..20).map(|i| i as i64).collect(),
open: vec![
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
],
high: vec![
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
],
low: vec![
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
],
close: vec![
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
],
volume: vec![1000.0; 20],
};
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries: vec![
false, true, false, false, false, false, false, false, false, false, false, true,
false, false, false, false, false, false, false, false,
],
exits: vec![
false, false, false, false, false, true, false, false, false, false, false, false,
false, false, false, true, false, false, false, false,
],
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
(ohlcv, signals)
}
#[test]
fn test_single_backtest() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let backtest = SingleBacktest::new(config);
let (ohlcv, signals) = sample_data();
let result = backtest.run(&ohlcv, &signals);
assert_eq!(result.trades.len(), 2);
assert!(result.metrics.total_return_pct > 0.0);
}
#[test]
fn test_from_arrays() {
let config = BacktestConfig::default();
let backtest = SingleBacktest::new(config);
let timestamps: Vec<i64> = (0..10).collect();
let close: Vec<f64> = (100..110).map(|x| x as f64).collect();
let open = close.clone();
let high: Vec<f64> = close.iter().map(|x| x + 1.0).collect();
let low: Vec<f64> = close.iter().map(|x| x - 1.0).collect();
let volume = vec![1000.0; 10];
let entries = vec![false, true, false, false, false, false, false, false, false, false];
let exits = vec![false, false, false, false, false, true, false, false, false, false];
let result = backtest.run_from_arrays(
&timestamps,
&open,
&high,
&low,
&close,
&volume,
&entries,
&exits,
1,
"TEST",
);
assert_eq!(result.trades.len(), 1);
}
}
+606
View File
@@ -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(&current_premiums);
}
// Calculate unrealized P&L for exit checks
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
// Check 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(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
assert!(result.equity_curve.len() == timestamps.len());
}
#[test]
fn test_iron_condor_backtest() {
let base_config = BacktestConfig::default();
let config = create_iron_condor_config(
base_config,
95.0, // short put
90.0, // long put
105.0, // short call
110.0, // long call
50,
);
let backtest = SpreadBacktest::new(config);
let n = 20;
let timestamps: Vec<i64> = (0..n as i64).collect();
let underlying: Vec<f64> = vec![100.0; n];
// Four legs: short put, long put, short call, long call
let legs_premiums = vec![
vec![3.0; n], // short put
vec![1.5; n], // long put
vec![3.0; n], // short call
vec![1.5; n], // long call
];
let mut entries = vec![false; n];
entries[1] = true;
let mut exits = vec![false; n];
exits[15] = true;
let result = backtest.run(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
}
}
+359
View File
@@ -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);
}
}