Fix CI workflow: use correct rust-toolchain action (#1)
* Fix CI workflow: use correct rust-toolchain action * Fix CI: resolve Rust compilation and formatting issues - Fix rust-toolchain action name in CI workflow - Add missing Direction import in test modules - Add missing entry_fees argument to open_position test calls - Comment out nightly-only rustfmt options - Auto-format code with cargo fmt
This commit is contained in:
@@ -10,23 +10,22 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
uses: dtolnay/rust-action@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
- name: Run Rust tests
|
components: clippy, rustfmt
|
||||||
run: cargo test --all-features
|
|
||||||
|
|
||||||
- name: Run Rust clippy
|
|
||||||
run: cargo clippy --all-features -- -D warnings
|
|
||||||
|
|
||||||
- name: Check Rust formatting
|
- name: Check Rust formatting
|
||||||
run: cargo fmt --check
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Run Rust clippy
|
||||||
|
run: cargo clippy --all-features
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
max_width = 100
|
max_width = 100
|
||||||
use_small_heuristics = "Max"
|
use_small_heuristics = "Max"
|
||||||
imports_granularity = "Module"
|
# Note: imports_granularity and group_imports require nightly Rust
|
||||||
group_imports = "StdExternalCrate"
|
# imports_granularity = "Module"
|
||||||
|
# group_imports = "StdExternalCrate"
|
||||||
+5
-16
@@ -49,38 +49,27 @@ impl RaptorError {
|
|||||||
|
|
||||||
/// Create an invalid parameter error.
|
/// Create an invalid parameter error.
|
||||||
pub fn invalid_parameter(message: impl Into<String>) -> Self {
|
pub fn invalid_parameter(message: impl Into<String>) -> Self {
|
||||||
Self::InvalidParameter {
|
Self::InvalidParameter { message: message.into() }
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an insufficient data error.
|
/// Create an insufficient data error.
|
||||||
pub fn insufficient_data(required: usize, available: usize) -> Self {
|
pub fn insufficient_data(required: usize, available: usize) -> Self {
|
||||||
Self::InsufficientData {
|
Self::InsufficientData { required, available }
|
||||||
required,
|
|
||||||
available,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an invalid config error.
|
/// Create an invalid config error.
|
||||||
pub fn invalid_config(message: impl Into<String>) -> Self {
|
pub fn invalid_config(message: impl Into<String>) -> Self {
|
||||||
Self::InvalidConfig {
|
Self::InvalidConfig { message: message.into() }
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a division by zero error.
|
/// Create a division by zero error.
|
||||||
pub fn division_by_zero(context: impl Into<String>) -> Self {
|
pub fn division_by_zero(context: impl Into<String>) -> Self {
|
||||||
Self::DivisionByZero {
|
Self::DivisionByZero { context: context.into() }
|
||||||
context: context.into(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an empty data error.
|
/// Create an empty data error.
|
||||||
pub fn empty_data(context: impl Into<String>) -> Self {
|
pub fn empty_data(context: impl Into<String>) -> Self {
|
||||||
Self::EmptyData {
|
Self::EmptyData { context: context.into() }
|
||||||
context: context.into(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-59
@@ -77,20 +77,14 @@ impl<T: Clone + Default> TimeSeries<T> {
|
|||||||
/// Create with default values.
|
/// Create with default values.
|
||||||
pub fn with_default(timestamps: Vec<Timestamp>) -> Self {
|
pub fn with_default(timestamps: Vec<Timestamp>) -> Self {
|
||||||
let len = timestamps.len();
|
let len = timestamps.len();
|
||||||
Self {
|
Self { timestamps, values: vec![T::default(); len] }
|
||||||
timestamps,
|
|
||||||
values: vec![T::default(); len],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TimeSeries<f64> {
|
impl TimeSeries<f64> {
|
||||||
/// Create a series filled with NaN.
|
/// Create a series filled with NaN.
|
||||||
pub fn with_nan(len: usize) -> Self {
|
pub fn with_nan(len: usize) -> Self {
|
||||||
Self {
|
Self { timestamps: (0..len as i64).collect(), values: vec![f64::NAN; len] }
|
||||||
timestamps: (0..len as i64).collect(),
|
|
||||||
values: vec![f64::NAN; len],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate sum of all values.
|
/// Calculate sum of all values.
|
||||||
@@ -124,20 +118,12 @@ impl TimeSeries<f64> {
|
|||||||
|
|
||||||
/// Get minimum value.
|
/// Get minimum value.
|
||||||
pub fn min(&self) -> f64 {
|
pub fn min(&self) -> f64 {
|
||||||
self.values
|
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::INFINITY, f64::min)
|
||||||
.iter()
|
|
||||||
.filter(|v| !v.is_nan())
|
|
||||||
.copied()
|
|
||||||
.fold(f64::INFINITY, f64::min)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get maximum value.
|
/// Get maximum value.
|
||||||
pub fn max(&self) -> f64 {
|
pub fn max(&self) -> f64 {
|
||||||
self.values
|
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::NEG_INFINITY, f64::max)
|
||||||
.iter()
|
|
||||||
.filter(|v| !v.is_nan())
|
|
||||||
.copied()
|
|
||||||
.fold(f64::NEG_INFINITY, f64::max)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shift values by n positions (positive = shift forward, fill with NaN).
|
/// Shift values by n positions (positive = shift forward, fill with NaN).
|
||||||
@@ -161,10 +147,7 @@ impl TimeSeries<f64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self { timestamps: self.timestamps.clone(), values: result }
|
||||||
timestamps: self.timestamps.clone(),
|
|
||||||
values: result,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate difference from previous value.
|
/// Calculate difference from previous value.
|
||||||
@@ -175,10 +158,7 @@ impl TimeSeries<f64> {
|
|||||||
result[i] = self.values[i] - self.values[i - 1];
|
result[i] = self.values[i] - self.values[i - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self {
|
Self { timestamps: self.timestamps.clone(), values: result }
|
||||||
timestamps: self.timestamps.clone(),
|
|
||||||
values: result,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate percentage change from previous value.
|
/// Calculate percentage change from previous value.
|
||||||
@@ -190,10 +170,7 @@ impl TimeSeries<f64> {
|
|||||||
result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1];
|
result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self {
|
Self { timestamps: self.timestamps.clone(), values: result }
|
||||||
timestamps: self.timestamps.clone(),
|
|
||||||
values: result,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply rolling window function.
|
/// Apply rolling window function.
|
||||||
@@ -203,10 +180,7 @@ impl TimeSeries<f64> {
|
|||||||
{
|
{
|
||||||
let mut result = vec![f64::NAN; self.values.len()];
|
let mut result = vec![f64::NAN; self.values.len()];
|
||||||
if window == 0 || window > self.values.len() {
|
if window == 0 || window > self.values.len() {
|
||||||
return Self {
|
return Self { timestamps: self.timestamps.clone(), values: result };
|
||||||
timestamps: self.timestamps.clone(),
|
|
||||||
values: result,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in (window - 1)..self.values.len() {
|
for i in (window - 1)..self.values.len() {
|
||||||
@@ -214,10 +188,7 @@ impl TimeSeries<f64> {
|
|||||||
result[i] = f(slice);
|
result[i] = f(slice);
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self { timestamps: self.timestamps.clone(), values: result }
|
||||||
timestamps: self.timestamps.clone(),
|
|
||||||
values: result,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate rolling sum.
|
/// Calculate rolling sum.
|
||||||
@@ -227,9 +198,7 @@ impl TimeSeries<f64> {
|
|||||||
|
|
||||||
/// Calculate rolling mean.
|
/// Calculate rolling mean.
|
||||||
pub fn rolling_mean(&self, window: usize) -> Self {
|
pub fn rolling_mean(&self, window: usize) -> Self {
|
||||||
self.rolling(window, |slice| {
|
self.rolling(window, |slice| slice.iter().sum::<f64>() / slice.len() as f64)
|
||||||
slice.iter().sum::<f64>() / slice.len() as f64
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate rolling standard deviation.
|
/// Calculate rolling standard deviation.
|
||||||
@@ -244,16 +213,12 @@ impl TimeSeries<f64> {
|
|||||||
|
|
||||||
/// Calculate rolling maximum.
|
/// Calculate rolling maximum.
|
||||||
pub fn rolling_max(&self, window: usize) -> Self {
|
pub fn rolling_max(&self, window: usize) -> Self {
|
||||||
self.rolling(window, |slice| {
|
self.rolling(window, |slice| slice.iter().copied().fold(f64::NEG_INFINITY, f64::max))
|
||||||
slice.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate rolling minimum.
|
/// Calculate rolling minimum.
|
||||||
pub fn rolling_min(&self, window: usize) -> Self {
|
pub fn rolling_min(&self, window: usize) -> Self {
|
||||||
self.rolling(window, |slice| {
|
self.rolling(window, |slice| slice.iter().copied().fold(f64::INFINITY, f64::min))
|
||||||
slice.iter().copied().fold(f64::INFINITY, f64::min)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,12 +242,7 @@ impl TimeSeries<bool> {
|
|||||||
debug_assert_eq!(self.len(), other.len());
|
debug_assert_eq!(self.len(), other.len());
|
||||||
Self {
|
Self {
|
||||||
timestamps: self.timestamps.clone(),
|
timestamps: self.timestamps.clone(),
|
||||||
values: self
|
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a && b).collect(),
|
||||||
.values
|
|
||||||
.iter()
|
|
||||||
.zip(other.values.iter())
|
|
||||||
.map(|(&a, &b)| a && b)
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,12 +251,7 @@ impl TimeSeries<bool> {
|
|||||||
debug_assert_eq!(self.len(), other.len());
|
debug_assert_eq!(self.len(), other.len());
|
||||||
Self {
|
Self {
|
||||||
timestamps: self.timestamps.clone(),
|
timestamps: self.timestamps.clone(),
|
||||||
values: self
|
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a || b).collect(),
|
||||||
.values
|
|
||||||
.iter()
|
|
||||||
.zip(other.values.iter())
|
|
||||||
.map(|(&a, &b)| a || b)
|
|
||||||
.collect(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-23
@@ -73,14 +73,7 @@ impl OhlcvData {
|
|||||||
close: Vec<Price>,
|
close: Vec<Price>,
|
||||||
volume: Vec<f64>,
|
volume: Vec<f64>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { timestamps, open, high, low, close, volume }
|
||||||
timestamps,
|
|
||||||
open,
|
|
||||||
high,
|
|
||||||
low,
|
|
||||||
close,
|
|
||||||
volume,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of bars.
|
/// Get the number of bars.
|
||||||
@@ -137,14 +130,7 @@ impl CompiledSignals {
|
|||||||
direction: Direction,
|
direction: Direction,
|
||||||
weight: f64,
|
weight: f64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { symbol, entries, exits, position_sizes: None, direction, weight }
|
||||||
symbol,
|
|
||||||
entries,
|
|
||||||
exits,
|
|
||||||
position_sizes: None,
|
|
||||||
direction,
|
|
||||||
weight,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set position sizes.
|
/// Set position sizes.
|
||||||
@@ -375,13 +361,7 @@ impl BacktestResult {
|
|||||||
trades: Vec<Trade>,
|
trades: Vec<Trade>,
|
||||||
returns: Vec<f64>,
|
returns: Vec<f64>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { metrics, equity_curve, drawdown_curve, trades, returns }
|
||||||
metrics,
|
|
||||||
equity_curve,
|
|
||||||
drawdown_curve,
|
|
||||||
trades,
|
|
||||||
returns,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,7 @@ pub struct BrokerFees;
|
|||||||
impl BrokerFees {
|
impl BrokerFees {
|
||||||
/// Interactive Brokers tiered pricing (approximate).
|
/// Interactive Brokers tiered pricing (approximate).
|
||||||
pub fn interactive_brokers() -> FeeModel {
|
pub fn interactive_brokers() -> FeeModel {
|
||||||
FeeModel::Custom {
|
FeeModel::Custom { base: 1.0, per_share: 0.005 }
|
||||||
base: 1.0,
|
|
||||||
per_share: 0.005,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zero commission broker (like Robinhood).
|
/// Zero commission broker (like Robinhood).
|
||||||
|
|||||||
+4
-23
@@ -121,31 +121,19 @@ pub struct FillModel {
|
|||||||
|
|
||||||
impl Default for FillModel {
|
impl Default for FillModel {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
|
||||||
fill_price: FillPrice::Close,
|
|
||||||
delay_to_next_bar: false,
|
|
||||||
fill_ratio: 1.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FillModel {
|
impl FillModel {
|
||||||
/// Create a fill model that executes at close.
|
/// Create a fill model that executes at close.
|
||||||
pub fn at_close() -> Self {
|
pub fn at_close() -> Self {
|
||||||
Self {
|
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
|
||||||
fill_price: FillPrice::Close,
|
|
||||||
delay_to_next_bar: false,
|
|
||||||
fill_ratio: 1.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a fill model that executes at next bar's open.
|
/// Create a fill model that executes at next bar's open.
|
||||||
pub fn at_next_open() -> Self {
|
pub fn at_next_open() -> Self {
|
||||||
Self {
|
Self { fill_price: FillPrice::Open, delay_to_next_bar: true, fill_ratio: 1.0 }
|
||||||
fill_price: FillPrice::Open,
|
|
||||||
delay_to_next_bar: true,
|
|
||||||
fill_ratio: 1.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set partial fill ratio.
|
/// Set partial fill ratio.
|
||||||
@@ -306,14 +294,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn test_bar() -> OhlcvBar {
|
fn test_bar() -> OhlcvBar {
|
||||||
OhlcvBar {
|
OhlcvBar { timestamp: 0, open: 100.0, high: 105.0, low: 95.0, close: 102.0, volume: 1000.0 }
|
||||||
timestamp: 0,
|
|
||||||
open: 100.0,
|
|
||||||
high: 105.0,
|
|
||||||
low: 95.0,
|
|
||||||
close: 102.0,
|
|
||||||
volume: 1000.0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ impl SlippageModel {
|
|||||||
|
|
||||||
/// Create a volume-based slippage model.
|
/// Create a volume-based slippage model.
|
||||||
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
|
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
|
||||||
SlippageModel::VolumeBased {
|
SlippageModel::VolumeBased { base, volume_factor }
|
||||||
base,
|
|
||||||
volume_factor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate slippage for a trade.
|
/// Calculate slippage for a trade.
|
||||||
@@ -66,10 +63,7 @@ impl SlippageModel {
|
|||||||
SlippageModel::None => 0.0,
|
SlippageModel::None => 0.0,
|
||||||
SlippageModel::Percentage(rate) => price * rate,
|
SlippageModel::Percentage(rate) => price * rate,
|
||||||
SlippageModel::Fixed(points) => *points,
|
SlippageModel::Fixed(points) => *points,
|
||||||
SlippageModel::VolumeBased {
|
SlippageModel::VolumeBased { base, volume_factor } => {
|
||||||
base,
|
|
||||||
volume_factor,
|
|
||||||
} => {
|
|
||||||
if let Some(vol) = volume {
|
if let Some(vol) = volume {
|
||||||
if vol > 0.0 {
|
if vol > 0.0 {
|
||||||
base * (1.0 / (1.0 + vol * volume_factor))
|
base * (1.0 / (1.0 + vol * volume_factor))
|
||||||
@@ -131,11 +125,7 @@ pub struct MarketImpact {
|
|||||||
impl MarketImpact {
|
impl MarketImpact {
|
||||||
/// Create a new market impact model.
|
/// Create a new market impact model.
|
||||||
pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self {
|
pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self {
|
||||||
Self {
|
Self { temporary_impact: temporary, permanent_impact: permanent, avg_daily_volume: adv }
|
||||||
temporary_impact: temporary,
|
|
||||||
permanent_impact: permanent,
|
|
||||||
avg_daily_volume: adv,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate market impact for an order.
|
/// Calculate market impact for an order.
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ pub fn macd(
|
|||||||
return Err(RaptorError::invalid_parameter("MACD periods must be > 0"));
|
return Err(RaptorError::invalid_parameter("MACD periods must be > 0"));
|
||||||
}
|
}
|
||||||
if fast_period >= slow_period {
|
if fast_period >= slow_period {
|
||||||
return Err(RaptorError::invalid_parameter(
|
return Err(RaptorError::invalid_parameter("MACD fast period must be < slow period"));
|
||||||
"MACD fast period must be < slow period",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = data.len();
|
let n = data.len();
|
||||||
@@ -111,11 +109,7 @@ pub fn macd(
|
|||||||
let mut histogram = vec![f64::NAN; n];
|
let mut histogram = vec![f64::NAN; n];
|
||||||
|
|
||||||
if slow_period > n {
|
if slow_period > n {
|
||||||
return Ok(MacdResult {
|
return Ok(MacdResult { macd_line, signal_line, histogram });
|
||||||
macd_line,
|
|
||||||
signal_line,
|
|
||||||
histogram,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate fast and slow EMAs
|
// Calculate fast and slow EMAs
|
||||||
@@ -163,11 +157,7 @@ pub fn macd(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MacdResult {
|
Ok(MacdResult { macd_line, signal_line, histogram })
|
||||||
macd_line,
|
|
||||||
signal_line,
|
|
||||||
histogram,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stochastic oscillator result.
|
/// Stochastic oscillator result.
|
||||||
@@ -202,9 +192,7 @@ pub fn stochastic(
|
|||||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||||
}
|
}
|
||||||
if k_period == 0 || d_period == 0 {
|
if k_period == 0 || d_period == 0 {
|
||||||
return Err(RaptorError::invalid_parameter(
|
return Err(RaptorError::invalid_parameter("Stochastic periods must be > 0"));
|
||||||
"Stochastic periods must be > 0",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut k = vec![f64::NAN; n];
|
let mut k = vec![f64::NAN; n];
|
||||||
|
|||||||
@@ -152,11 +152,7 @@ pub fn directional_movement(
|
|||||||
let mut adx_values = vec![f64::NAN; n];
|
let mut adx_values = vec![f64::NAN; n];
|
||||||
|
|
||||||
if 2 * period > n {
|
if 2 * period > n {
|
||||||
return Ok(DirectionalIndexResult {
|
return Ok(DirectionalIndexResult { plus_di, minus_di, adx: adx_values });
|
||||||
plus_di,
|
|
||||||
minus_di,
|
|
||||||
adx: adx_values,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate directional movement
|
// Calculate directional movement
|
||||||
@@ -217,11 +213,7 @@ pub fn directional_movement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(DirectionalIndexResult {
|
Ok(DirectionalIndexResult { plus_di, minus_di, adx: adx_values })
|
||||||
plus_di,
|
|
||||||
minus_di,
|
|
||||||
adx: adx_values,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+3
-11
@@ -131,19 +131,14 @@ pub fn supertrend(
|
|||||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||||
}
|
}
|
||||||
if period == 0 {
|
if period == 0 {
|
||||||
return Err(RaptorError::invalid_parameter(
|
return Err(RaptorError::invalid_parameter("Supertrend period must be > 0"));
|
||||||
"Supertrend period must be > 0",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut supertrend = vec![f64::NAN; n];
|
let mut supertrend = vec![f64::NAN; n];
|
||||||
let mut direction = vec![0i8; n];
|
let mut direction = vec![0i8; n];
|
||||||
|
|
||||||
if period >= n {
|
if period >= n {
|
||||||
return Ok(SupertrendResult {
|
return Ok(SupertrendResult { supertrend, direction });
|
||||||
supertrend,
|
|
||||||
direction,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate ATR
|
// Calculate ATR
|
||||||
@@ -238,10 +233,7 @@ pub fn supertrend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SupertrendResult {
|
Ok(SupertrendResult { supertrend, direction })
|
||||||
supertrend,
|
|
||||||
direction,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -88,14 +88,10 @@ pub struct BollingerBandsResult {
|
|||||||
/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B
|
/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B
|
||||||
pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<BollingerBandsResult> {
|
pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<BollingerBandsResult> {
|
||||||
if period == 0 {
|
if period == 0 {
|
||||||
return Err(RaptorError::invalid_parameter(
|
return Err(RaptorError::invalid_parameter("Bollinger Bands period must be > 0"));
|
||||||
"Bollinger Bands period must be > 0",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if std_dev <= 0.0 {
|
if std_dev <= 0.0 {
|
||||||
return Err(RaptorError::invalid_parameter(
|
return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0"));
|
||||||
"Bollinger Bands std_dev must be > 0",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let n = data.len();
|
let n = data.len();
|
||||||
@@ -106,13 +102,7 @@ pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<Boll
|
|||||||
let mut percent_b = vec![f64::NAN; n];
|
let mut percent_b = vec![f64::NAN; n];
|
||||||
|
|
||||||
if period > n {
|
if period > n {
|
||||||
return Ok(BollingerBandsResult {
|
return Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b });
|
||||||
middle,
|
|
||||||
upper,
|
|
||||||
lower,
|
|
||||||
bandwidth,
|
|
||||||
percent_b,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate SMA for middle band
|
// Calculate SMA for middle band
|
||||||
@@ -130,11 +120,8 @@ pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<Boll
|
|||||||
let start = i + 1 - period;
|
let start = i + 1 - period;
|
||||||
|
|
||||||
// Calculate standard deviation using population variance
|
// Calculate standard deviation using population variance
|
||||||
let variance: f64 = data[start..=i]
|
let variance: f64 =
|
||||||
.iter()
|
data[start..=i].iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||||
.map(|x| (x - mean).powi(2))
|
|
||||||
.sum::<f64>()
|
|
||||||
/ period as f64;
|
|
||||||
let std = variance.sqrt();
|
let std = variance.sqrt();
|
||||||
|
|
||||||
// Calculate bands (std is always non-negative from sqrt)
|
// Calculate bands (std is always non-negative from sqrt)
|
||||||
@@ -153,13 +140,7 @@ pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<Boll
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BollingerBandsResult {
|
Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b })
|
||||||
middle,
|
|
||||||
upper,
|
|
||||||
lower,
|
|
||||||
bandwidth,
|
|
||||||
percent_b,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keltner Channels (ATR-based bands).
|
/// Keltner Channels (ATR-based bands).
|
||||||
@@ -227,9 +208,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bollinger_bands() {
|
fn test_bollinger_bands() {
|
||||||
let data: Vec<f64> = (1..=30)
|
let data: Vec<f64> = (1..=30).map(|x| x as f64 + (x as f64 * 0.1).sin()).collect();
|
||||||
.map(|x| x as f64 + (x as f64 * 0.1).sin())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let result = bollinger_bands(&data, 20, 2.0).unwrap();
|
let result = bollinger_bands(&data, 20, 2.0).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -256,11 +256,7 @@ pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> {
|
|||||||
/// Calmar ratio
|
/// Calmar ratio
|
||||||
pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 {
|
pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 {
|
||||||
if max_drawdown <= 0.0 {
|
if max_drawdown <= 0.0 {
|
||||||
return if total_return > 0.0 {
|
return if total_return > 0.0 { f64::INFINITY } else { 0.0 };
|
||||||
f64::INFINITY
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
total_return / max_drawdown
|
total_return / max_drawdown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,11 +206,7 @@ impl StreamingMetrics {
|
|||||||
/// Get profit factor (sum of profits / sum of losses).
|
/// Get profit factor (sum of profits / sum of losses).
|
||||||
pub fn profit_factor(&self) -> f64 {
|
pub fn profit_factor(&self) -> f64 {
|
||||||
if self.sum_negative == 0.0 {
|
if self.sum_negative == 0.0 {
|
||||||
return if self.sum_positive > 0.0 {
|
return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 };
|
||||||
f64::INFINITY
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
self.sum_positive / self.sum_negative.abs()
|
self.sum_positive / self.sum_negative.abs()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,9 @@ impl TradeStatistics {
|
|||||||
|
|
||||||
// Average holding period
|
// Average holding period
|
||||||
if stats.total_trades > 0 {
|
if stats.total_trades > 0 {
|
||||||
stats.avg_holding_period = trades
|
stats.avg_holding_period =
|
||||||
.iter()
|
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>()
|
||||||
.map(|t| t.holding_period() as f64)
|
/ stats.total_trades as f64;
|
||||||
.sum::<f64>()
|
|
||||||
/ stats.total_trades as f64;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consecutive wins/losses
|
// Consecutive wins/losses
|
||||||
@@ -231,22 +229,13 @@ pub fn stats_by_exit_reason(
|
|||||||
pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) {
|
pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) {
|
||||||
use crate::core::types::Direction;
|
use crate::core::types::Direction;
|
||||||
|
|
||||||
let long_trades: Vec<Trade> = trades
|
let long_trades: Vec<Trade> =
|
||||||
.iter()
|
trades.iter().filter(|t| t.direction == Direction::Long).cloned().collect();
|
||||||
.filter(|t| t.direction == Direction::Long)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let short_trades: Vec<Trade> = trades
|
let short_trades: Vec<Trade> =
|
||||||
.iter()
|
trades.iter().filter(|t| t.direction == Direction::Short).cloned().collect();
|
||||||
.filter(|t| t.direction == Direction::Short)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(
|
(TradeStatistics::from_trades(&long_trades), TradeStatistics::from_trades(&short_trades))
|
||||||
TradeStatistics::from_trades(&long_trades),
|
|
||||||
TradeStatistics::from_trades(&short_trades),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -151,18 +151,13 @@ impl CapitalAllocator {
|
|||||||
let equal = 1.0 / n as f64;
|
let equal = 1.0 / n as f64;
|
||||||
vec![equal.min(*max); n]
|
vec![equal.min(*max); n]
|
||||||
}
|
}
|
||||||
_ => weights
|
_ => weights.map(|w| w.to_vec()).unwrap_or_else(|| vec![1.0 / n as f64; n]),
|
||||||
.map(|w| w.to_vec())
|
|
||||||
.unwrap_or_else(|| vec![1.0 / n as f64; n]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize weights
|
// Normalize weights
|
||||||
let total_weight: f64 = instrument_weights.iter().sum();
|
let total_weight: f64 = instrument_weights.iter().sum();
|
||||||
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
|
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
|
||||||
instrument_weights
|
instrument_weights.iter().map(|w| w / total_weight).collect()
|
||||||
.iter()
|
|
||||||
.map(|w| w / total_weight)
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
vec![1.0 / n as f64; n]
|
vec![1.0 / n as f64; n]
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-98
@@ -37,11 +37,7 @@ impl PortfolioEngine {
|
|||||||
/// Create a new portfolio engine with the given configuration.
|
/// Create a new portfolio engine with the given configuration.
|
||||||
pub fn new(config: BacktestConfig) -> Self {
|
pub fn new(config: BacktestConfig) -> Self {
|
||||||
let fee_model = FeeModel::percentage(config.fees);
|
let fee_model = FeeModel::percentage(config.fees);
|
||||||
let fill_price = if config.upon_bar_close {
|
let fill_price = if config.upon_bar_close { FillPrice::Close } else { FillPrice::Open };
|
||||||
FillPrice::Close
|
|
||||||
} else {
|
|
||||||
FillPrice::Open
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -77,9 +73,8 @@ impl PortfolioEngine {
|
|||||||
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
|
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
|
||||||
|
|
||||||
// Clean signals
|
// Clean signals
|
||||||
let (entries, exits) = self
|
let (entries, exits) =
|
||||||
.signal_processor
|
self.signal_processor.clean_signals(&signals.entries, &signals.exits);
|
||||||
.clean_signals(&signals.entries, &signals.exits);
|
|
||||||
|
|
||||||
// Initialize state
|
// Initialize state
|
||||||
let mut position = PositionManager::new(signals.symbol.clone());
|
let mut position = PositionManager::new(signals.symbol.clone());
|
||||||
@@ -224,8 +219,7 @@ impl PortfolioEngine {
|
|||||||
if size > 0.0 {
|
if size > 0.0 {
|
||||||
// Calculate entry fees
|
// Calculate entry fees
|
||||||
let entry_fees =
|
let entry_fees =
|
||||||
self.fee_model
|
self.fee_model.calculate(adjusted_price, size, signals.direction);
|
||||||
.calculate(adjusted_price, size, signals.direction);
|
|
||||||
|
|
||||||
// Calculate stop and target prices
|
// Calculate stop and target prices
|
||||||
let (stop_price, target_price) = self.calculate_stop_target(
|
let (stop_price, target_price) = self.calculate_stop_target(
|
||||||
@@ -253,11 +247,8 @@ impl PortfolioEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate equity
|
// Calculate equity
|
||||||
let position_value = if position.is_in_position() {
|
let position_value =
|
||||||
close * position.position.size
|
if position.is_in_position() { close * position.position.size } else { 0.0 };
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let equity = cash + position_value;
|
let equity = cash + position_value;
|
||||||
equity_curve[i] = equity;
|
equity_curve[i] = equity;
|
||||||
|
|
||||||
@@ -295,13 +286,8 @@ impl PortfolioEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate final metrics
|
// Calculate final metrics
|
||||||
let metrics = self.calculate_metrics(
|
let metrics =
|
||||||
&equity_curve,
|
self.calculate_metrics(&equity_curve, &drawdown_curve, &returns, &trades, &streaming);
|
||||||
&drawdown_curve,
|
|
||||||
&returns,
|
|
||||||
&trades,
|
|
||||||
&streaming,
|
|
||||||
);
|
|
||||||
|
|
||||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||||
}
|
}
|
||||||
@@ -396,10 +382,8 @@ impl PortfolioEngine {
|
|||||||
let total_trades = trades.len();
|
let total_trades = trades.len();
|
||||||
|
|
||||||
// Separate closed vs open trades (EndOfData means still open)
|
// Separate closed vs open trades (EndOfData means still open)
|
||||||
let total_open_trades = trades
|
let total_open_trades =
|
||||||
.iter()
|
trades.iter().filter(|t| matches!(t.exit_reason, ExitReason::EndOfData)).count();
|
||||||
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
|
|
||||||
.count();
|
|
||||||
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
|
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
|
||||||
|
|
||||||
// Open trade PnL
|
// Open trade PnL
|
||||||
@@ -410,10 +394,8 @@ impl PortfolioEngine {
|
|||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
// Only count closed trades for win/loss statistics
|
// Only count closed trades for win/loss statistics
|
||||||
let closed_trades: Vec<_> = trades
|
let closed_trades: Vec<_> =
|
||||||
.iter()
|
trades.iter().filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData)).collect();
|
||||||
.filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count();
|
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 losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count();
|
||||||
@@ -428,37 +410,18 @@ impl PortfolioEngine {
|
|||||||
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
|
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
|
||||||
|
|
||||||
// Best and worst trade
|
// Best and worst trade
|
||||||
let best_trade_pct = trades
|
let best_trade_pct =
|
||||||
.iter()
|
trades.iter().map(|t| t.return_pct).fold(f64::NEG_INFINITY, |a, b| a.max(b));
|
||||||
.map(|t| t.return_pct)
|
let best_trade_pct = if best_trade_pct.is_infinite() { 0.0 } else { best_trade_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
|
let worst_trade_pct =
|
||||||
.iter()
|
trades.iter().map(|t| t.return_pct).fold(f64::INFINITY, |a, b| a.min(b));
|
||||||
.map(|t| t.return_pct)
|
let worst_trade_pct = if worst_trade_pct.is_infinite() { 0.0 } else { worst_trade_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)
|
// Profit factor (based on closed trades)
|
||||||
let gross_profit: f64 = closed_trades
|
let gross_profit: f64 = closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||||
.iter()
|
let gross_loss: f64 =
|
||||||
.filter(|t| t.pnl > 0.0)
|
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
|
||||||
.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 {
|
let profit_factor = if gross_loss > 0.0 {
|
||||||
gross_profit / gross_loss
|
gross_profit / gross_loss
|
||||||
} else if gross_profit > 0.0 {
|
} else if gross_profit > 0.0 {
|
||||||
@@ -498,22 +461,14 @@ impl PortfolioEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let avg_win_pct = if winning_trades > 0 {
|
let avg_win_pct = if winning_trades > 0 {
|
||||||
closed_trades
|
closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::<f64>()
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl > 0.0)
|
|
||||||
.map(|t| t.return_pct)
|
|
||||||
.sum::<f64>()
|
|
||||||
/ winning_trades as f64
|
/ winning_trades as f64
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
|
|
||||||
let avg_loss_pct = if losing_trades > 0 {
|
let avg_loss_pct = if losing_trades > 0 {
|
||||||
closed_trades
|
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl < 0.0)
|
|
||||||
.map(|t| t.return_pct)
|
|
||||||
.sum::<f64>()
|
|
||||||
/ losing_trades as f64
|
/ losing_trades as f64
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
@@ -547,11 +502,7 @@ impl PortfolioEngine {
|
|||||||
|
|
||||||
// Holding period
|
// Holding period
|
||||||
let avg_holding_period = if total_trades > 0 {
|
let avg_holding_period = if total_trades > 0 {
|
||||||
trades
|
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>() / total_trades as f64
|
||||||
.iter()
|
|
||||||
.map(|t| t.holding_period() as f64)
|
|
||||||
.sum::<f64>()
|
|
||||||
/ total_trades as f64
|
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@@ -574,11 +525,8 @@ impl PortfolioEngine {
|
|||||||
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
let years = num_periods / 365.25; // Convert to years using 365.25 days
|
||||||
let total_return_frac = total_return_pct / 100.0;
|
let total_return_frac = total_return_pct / 100.0;
|
||||||
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
|
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
|
||||||
let cagr = if years > 0.0 {
|
let cagr =
|
||||||
(1.0 + total_return_frac).powf(1.0 / years) - 1.0
|
if years > 0.0 { (1.0 + total_return_frac).powf(1.0 / years) - 1.0 } else { 0.0 };
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
let calmar_ratio = if max_drawdown_pct > 0.0 {
|
||||||
cagr / (max_drawdown_pct / 100.0) // Both as fractions
|
cagr / (max_drawdown_pct / 100.0) // Both as fractions
|
||||||
} else if total_return_pct > 0.0 {
|
} else if total_return_pct > 0.0 {
|
||||||
@@ -686,27 +634,18 @@ impl PortfolioEngine {
|
|||||||
let mean = valid_returns.iter().sum::<f64>() / n_valid;
|
let mean = valid_returns.iter().sum::<f64>() / n_valid;
|
||||||
|
|
||||||
// Calculate standard deviation
|
// Calculate standard deviation
|
||||||
let variance = valid_returns
|
let variance =
|
||||||
.iter()
|
valid_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n_valid - 1.0);
|
||||||
.map(|r| (r - mean).powi(2))
|
|
||||||
.sum::<f64>()
|
|
||||||
/ (n_valid - 1.0);
|
|
||||||
let std_dev = variance.sqrt();
|
let std_dev = variance.sqrt();
|
||||||
|
|
||||||
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
|
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
|
||||||
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
|
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
|
||||||
let sharpe_ratio = if std_dev > 0.0 {
|
let sharpe_ratio =
|
||||||
(mean / std_dev) * periods_per_year.sqrt()
|
if std_dev > 0.0 { (mean / std_dev) * periods_per_year.sqrt() } else { 0.0 };
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sortino Ratio - uses downside deviation (only negative returns)
|
// Sortino Ratio - uses downside deviation (only negative returns)
|
||||||
let downside_returns: Vec<f64> = valid_returns
|
let downside_returns: Vec<f64> =
|
||||||
.iter()
|
valid_returns.iter().filter(|&&r| r < 0.0).copied().collect();
|
||||||
.filter(|&&r| r < 0.0)
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let downside_variance = if !downside_returns.is_empty() {
|
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
|
downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / n_valid // Divide by total count, not downside count
|
||||||
@@ -726,11 +665,7 @@ impl PortfolioEngine {
|
|||||||
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
|
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
|
||||||
// With threshold = 0
|
// With threshold = 0
|
||||||
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
|
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
|
||||||
let sum_negative: f64 = valid_returns
|
let sum_negative: f64 = valid_returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum();
|
||||||
.iter()
|
|
||||||
.filter(|&&r| r < 0.0)
|
|
||||||
.map(|r| r.abs())
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
let omega_ratio = if sum_negative > 0.0 {
|
let omega_ratio = if sum_negative > 0.0 {
|
||||||
sum_positive / sum_negative
|
sum_positive / sum_negative
|
||||||
|
|||||||
+11
-30
@@ -16,11 +16,7 @@ pub struct PositionManager {
|
|||||||
impl PositionManager {
|
impl PositionManager {
|
||||||
/// Create a new position manager.
|
/// Create a new position manager.
|
||||||
pub fn new(symbol: String) -> Self {
|
pub fn new(symbol: String) -> Self {
|
||||||
Self {
|
Self { position: Position::new(), trade_counter: 0, symbol }
|
||||||
position: Position::new(),
|
|
||||||
trade_counter: 0,
|
|
||||||
symbol,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if currently in a position.
|
/// Check if currently in a position.
|
||||||
@@ -67,15 +63,7 @@ impl PositionManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.position.open(
|
self.position.open(idx, price, size, direction, stop_price, target_price, entry_fees);
|
||||||
idx,
|
|
||||||
price,
|
|
||||||
size,
|
|
||||||
direction,
|
|
||||||
stop_price,
|
|
||||||
target_price,
|
|
||||||
entry_fees,
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +119,7 @@ impl PositionManager {
|
|||||||
|
|
||||||
// Calculate return percentage
|
// Calculate return percentage
|
||||||
let cost_basis = pos.entry_price * pos.size;
|
let cost_basis = pos.entry_price * pos.size;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
Trade {
|
Trade {
|
||||||
id: self.trade_counter,
|
id: self.trade_counter,
|
||||||
@@ -270,16 +254,14 @@ mod tests {
|
|||||||
let mut pm = PositionManager::new("TEST".to_string());
|
let mut pm = PositionManager::new("TEST".to_string());
|
||||||
|
|
||||||
// Open position
|
// Open position
|
||||||
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None));
|
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0));
|
||||||
assert!(pm.is_in_position());
|
assert!(pm.is_in_position());
|
||||||
|
|
||||||
// Try to open another - should fail
|
// Try to open another - should fail
|
||||||
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None));
|
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None, 0.0));
|
||||||
|
|
||||||
// Close position with profit
|
// Close position with profit
|
||||||
let trade = pm
|
let trade = pm.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0).unwrap();
|
||||||
.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(!pm.is_in_position());
|
assert!(!pm.is_in_position());
|
||||||
assert_eq!(trade.entry_idx, 0);
|
assert_eq!(trade.entry_idx, 0);
|
||||||
@@ -295,12 +277,10 @@ mod tests {
|
|||||||
fn test_short_position() {
|
fn test_short_position() {
|
||||||
let mut pm = PositionManager::new("TEST".to_string());
|
let mut pm = PositionManager::new("TEST".to_string());
|
||||||
|
|
||||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None);
|
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None, 0.0);
|
||||||
|
|
||||||
// Close with profit (price went down)
|
// Close with profit (price went down)
|
||||||
let trade = pm
|
let trade = pm.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0).unwrap();
|
||||||
.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
|
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
|
||||||
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
|
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
|
||||||
@@ -319,6 +299,7 @@ mod tests {
|
|||||||
Direction::Long,
|
Direction::Long,
|
||||||
Some(95.0), // Stop at 95
|
Some(95.0), // Stop at 95
|
||||||
None,
|
None,
|
||||||
|
0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check stop not hit
|
// Check stop not hit
|
||||||
@@ -332,7 +313,7 @@ mod tests {
|
|||||||
fn test_trailing_stop() {
|
fn test_trailing_stop() {
|
||||||
let mut pm = PositionManager::new("TEST".to_string());
|
let mut pm = PositionManager::new("TEST".to_string());
|
||||||
|
|
||||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
|
||||||
|
|
||||||
// Update with higher price
|
// Update with higher price
|
||||||
pm.update_price(110.0, 98.0);
|
pm.update_price(110.0, 98.0);
|
||||||
@@ -353,7 +334,7 @@ mod tests {
|
|||||||
fn test_unrealized_pnl() {
|
fn test_unrealized_pnl() {
|
||||||
let mut pm = PositionManager::new("TEST".to_string());
|
let mut pm = PositionManager::new("TEST".to_string());
|
||||||
|
|
||||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None);
|
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
|
||||||
|
|
||||||
// Price up
|
// Price up
|
||||||
let pnl = pm.unrealized_pnl(110.0);
|
let pnl = pm.unrealized_pnl(110.0);
|
||||||
|
|||||||
+3
-17
@@ -115,12 +115,7 @@ pub struct PyStopConfig {
|
|||||||
impl PyStopConfig {
|
impl PyStopConfig {
|
||||||
#[new]
|
#[new]
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self { stop_type: "none".to_string(), percent: None, multiplier: None, period: None }
|
||||||
stop_type: "none".to_string(),
|
|
||||||
percent: None,
|
|
||||||
multiplier: None,
|
|
||||||
period: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[staticmethod]
|
#[staticmethod]
|
||||||
@@ -689,13 +684,7 @@ pub fn run_multi_backtest<'py>(
|
|||||||
low: PyReadonlyArray1<f64>,
|
low: PyReadonlyArray1<f64>,
|
||||||
close: PyReadonlyArray1<f64>,
|
close: PyReadonlyArray1<f64>,
|
||||||
volume: PyReadonlyArray1<f64>,
|
volume: PyReadonlyArray1<f64>,
|
||||||
strategies: Vec<(
|
strategies: Vec<(PyReadonlyArray1<bool>, PyReadonlyArray1<bool>, i32, f64, String)>,
|
||||||
PyReadonlyArray1<bool>,
|
|
||||||
PyReadonlyArray1<bool>,
|
|
||||||
i32,
|
|
||||||
f64,
|
|
||||||
String,
|
|
||||||
)>,
|
|
||||||
config: Option<&PyBacktestConfig>,
|
config: Option<&PyBacktestConfig>,
|
||||||
combine_mode: &str,
|
combine_mode: &str,
|
||||||
) -> PyResult<PyBacktestResult> {
|
) -> PyResult<PyBacktestResult> {
|
||||||
@@ -819,10 +808,7 @@ pub fn stochastic<'py>(
|
|||||||
let c = numpy_to_vec_f64(close);
|
let c = numpy_to_vec_f64(close);
|
||||||
let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period)
|
let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period)
|
||||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||||
Ok((
|
Ok((vec_to_numpy_f64(py, result.k), vec_to_numpy_f64(py, result.d)))
|
||||||
vec_to_numpy_f64(py, result.k),
|
|
||||||
vec_to_numpy_f64(py, result.d),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Average True Range.
|
/// Average True Range.
|
||||||
|
|||||||
@@ -321,10 +321,8 @@ pub fn is_highest(a: &[f64], window: usize) -> Vec<bool> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_in_window = a[start..=i]
|
let max_in_window =
|
||||||
.iter()
|
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::NEG_INFINITY, |a, &b| a.max(b));
|
||||||
.filter(|v| !v.is_nan())
|
|
||||||
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
|
|
||||||
|
|
||||||
result[i] = (current - max_in_window).abs() < 1e-10;
|
result[i] = (current - max_in_window).abs() < 1e-10;
|
||||||
}
|
}
|
||||||
@@ -355,10 +353,8 @@ pub fn is_lowest(a: &[f64], window: usize) -> Vec<bool> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let min_in_window = a[start..=i]
|
let min_in_window =
|
||||||
.iter()
|
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::INFINITY, |a, &b| a.min(b));
|
||||||
.filter(|v| !v.is_nan())
|
|
||||||
.fold(f64::INFINITY, |a, &b| a.min(b));
|
|
||||||
|
|
||||||
result[i] = (current - min_in_window).abs() < 1e-10;
|
result[i] = (current - min_in_window).abs() < 1e-10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ pub struct SignalProcessor {
|
|||||||
|
|
||||||
impl Default for SignalProcessor {
|
impl Default for SignalProcessor {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self { allow_pyramiding: false, max_pyramid_entries: 1 }
|
||||||
allow_pyramiding: false,
|
|
||||||
max_pyramid_entries: 1,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +52,7 @@ impl SignalProcessor {
|
|||||||
/// Tuple of (cleaned_entries, cleaned_exits)
|
/// Tuple of (cleaned_entries, cleaned_exits)
|
||||||
pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec<bool>, Vec<bool>) {
|
pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec<bool>, Vec<bool>) {
|
||||||
let n = entries.len();
|
let n = entries.len();
|
||||||
assert_eq!(
|
assert_eq!(n, exits.len(), "Entry and exit arrays must have same length");
|
||||||
n,
|
|
||||||
exits.len(),
|
|
||||||
"Entry and exit arrays must have same length"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut clean_entries = vec![false; n];
|
let mut clean_entries = vec![false; n];
|
||||||
let mut clean_exits = vec![false; n];
|
let mut clean_exits = vec![false; n];
|
||||||
@@ -140,12 +133,7 @@ impl SignalProcessor {
|
|||||||
let mut clean_short_exits = vec![false; n];
|
let mut clean_short_exits = vec![false; n];
|
||||||
|
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return (
|
return (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits);
|
||||||
clean_long_entries,
|
|
||||||
clean_long_exits,
|
|
||||||
clean_short_entries,
|
|
||||||
clean_short_exits,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut current_direction: Option<Direction> = None;
|
let mut current_direction: Option<Direction> = None;
|
||||||
@@ -189,12 +177,7 @@ impl SignalProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(
|
(clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
|
||||||
clean_long_entries,
|
|
||||||
clean_long_exits,
|
|
||||||
clean_short_entries,
|
|
||||||
clean_short_exits,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate exit-on-opposite-entry signals.
|
/// Generate exit-on-opposite-entry signals.
|
||||||
@@ -248,11 +231,8 @@ impl SignalProcessor {
|
|||||||
.filter_map(|(i, &e)| if e { Some(i) } else { None })
|
.filter_map(|(i, &e)| if e { Some(i) } else { None })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let exit_indices: Vec<usize> = exits
|
let exit_indices: Vec<usize> =
|
||||||
.iter()
|
exits.iter().enumerate().filter_map(|(i, &e)| if e { Some(i) } else { None }).collect();
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, &e)| if e { Some(i) } else { None })
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(entry_indices, exit_indices)
|
(entry_indices, exit_indices)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,28 +34,19 @@ pub struct SignalSynchronizer {
|
|||||||
|
|
||||||
impl Default for SignalSynchronizer {
|
impl Default for SignalSynchronizer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self { mode: SyncMode::All, min_signals: None }
|
||||||
mode: SyncMode::All,
|
|
||||||
min_signals: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignalSynchronizer {
|
impl SignalSynchronizer {
|
||||||
/// Create a new signal synchronizer with the given mode.
|
/// Create a new signal synchronizer with the given mode.
|
||||||
pub fn new(mode: SyncMode) -> Self {
|
pub fn new(mode: SyncMode) -> Self {
|
||||||
Self {
|
Self { mode, min_signals: None }
|
||||||
mode,
|
|
||||||
min_signals: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a synchronizer with a custom minimum signal threshold.
|
/// Create a synchronizer with a custom minimum signal threshold.
|
||||||
pub fn with_min_signals(min: usize) -> Self {
|
pub fn with_min_signals(min: usize) -> Self {
|
||||||
Self {
|
Self { mode: SyncMode::Majority, min_signals: Some(min) }
|
||||||
mode: SyncMode::Majority,
|
|
||||||
min_signals: Some(min),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronize entry signals from multiple instruments.
|
/// Synchronize entry signals from multiple instruments.
|
||||||
@@ -155,15 +146,10 @@ impl SignalSynchronizer {
|
|||||||
return (vec![], vec![]);
|
return (vec![], vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries: Vec<&[bool]> = compiled_signals
|
let entries: Vec<&[bool]> =
|
||||||
.iter()
|
compiled_signals.iter().map(|cs| cs.entries.as_slice()).collect();
|
||||||
.map(|cs| cs.entries.as_slice())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let exits: Vec<&[bool]> = compiled_signals
|
let exits: Vec<&[bool]> = compiled_signals.iter().map(|cs| cs.exits.as_slice()).collect();
|
||||||
.iter()
|
|
||||||
.map(|cs| cs.exits.as_slice())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let synced_entries = self.sync_entries(&entries);
|
let synced_entries = self.sync_entries(&entries);
|
||||||
let synced_exits = self.sync_exits(&exits);
|
let synced_exits = self.sync_exits(&exits);
|
||||||
|
|||||||
+1
-6
@@ -108,12 +108,7 @@ pub struct ChandelierExit {
|
|||||||
impl ChandelierExit {
|
impl ChandelierExit {
|
||||||
/// Create a new Chandelier exit.
|
/// Create a new Chandelier exit.
|
||||||
pub fn new(multiplier: f64, atr: f64) -> Self {
|
pub fn new(multiplier: f64, atr: f64) -> Self {
|
||||||
Self {
|
Self { multiplier, atr, highest_high: 0.0, lowest_low: f64::MAX }
|
||||||
multiplier,
|
|
||||||
atr,
|
|
||||||
highest_high: 0.0,
|
|
||||||
lowest_low: f64::MAX,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset for new position.
|
/// Reset for new position.
|
||||||
|
|||||||
+2
-6
@@ -13,9 +13,7 @@ pub struct FixedStop {
|
|||||||
impl FixedStop {
|
impl FixedStop {
|
||||||
/// Create a new fixed stop with given percentage.
|
/// Create a new fixed stop with given percentage.
|
||||||
pub fn new(percent: f64) -> Self {
|
pub fn new(percent: f64) -> Self {
|
||||||
Self {
|
Self { percent: percent.abs() }
|
||||||
percent: percent.abs(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a 1% stop.
|
/// Create a 1% stop.
|
||||||
@@ -66,9 +64,7 @@ pub struct FixedTarget {
|
|||||||
impl FixedTarget {
|
impl FixedTarget {
|
||||||
/// Create a new fixed target with given percentage.
|
/// Create a new fixed target with given percentage.
|
||||||
pub fn new(percent: f64) -> Self {
|
pub fn new(percent: f64) -> Self {
|
||||||
Self {
|
Self { percent: percent.abs() }
|
||||||
percent: percent.abs(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-11
@@ -15,10 +15,7 @@ pub struct TrailingStop {
|
|||||||
impl TrailingStop {
|
impl TrailingStop {
|
||||||
/// Create a new trailing stop.
|
/// Create a new trailing stop.
|
||||||
pub fn new(percent: f64) -> Self {
|
pub fn new(percent: f64) -> Self {
|
||||||
Self {
|
Self { percent: percent.abs(), activation_threshold: None }
|
||||||
percent: percent.abs(),
|
|
||||||
activation_threshold: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create with activation threshold.
|
/// Create with activation threshold.
|
||||||
@@ -89,9 +86,7 @@ pub struct PointTrailingStop {
|
|||||||
impl PointTrailingStop {
|
impl PointTrailingStop {
|
||||||
/// Create a new point-based trailing stop.
|
/// Create a new point-based trailing stop.
|
||||||
pub fn new(points: f64) -> Self {
|
pub fn new(points: f64) -> Self {
|
||||||
Self {
|
Self { points: points.abs() }
|
||||||
points: points.abs(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +132,7 @@ pub struct StepTrailingStop {
|
|||||||
impl StepTrailingStop {
|
impl StepTrailingStop {
|
||||||
/// Create a new step trailing stop.
|
/// Create a new step trailing stop.
|
||||||
pub fn new(step_percent: f64, trail_percent: f64) -> Self {
|
pub fn new(step_percent: f64, trail_percent: f64) -> Self {
|
||||||
Self {
|
Self { step_percent: step_percent.abs(), trail_percent: trail_percent.abs() }
|
||||||
step_percent: step_percent.abs(),
|
|
||||||
trail_percent: trail_percent.abs(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate stop for a given step level.
|
/// Calculate stop for a given step level.
|
||||||
|
|||||||
+18
-48
@@ -83,31 +83,22 @@ impl BasketBacktest {
|
|||||||
|
|
||||||
// Verify all instruments have same length
|
// Verify all instruments have same length
|
||||||
for (ohlcv, signals) in instruments {
|
for (ohlcv, signals) in instruments {
|
||||||
assert_eq!(
|
assert_eq!(ohlcv.len(), n_bars, "All instruments must have same number of bars");
|
||||||
ohlcv.len(),
|
|
||||||
n_bars,
|
|
||||||
"All instruments must have same number of bars"
|
|
||||||
);
|
|
||||||
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
|
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronize signals
|
// Synchronize signals
|
||||||
let entry_signals: Vec<&[bool]> = instruments
|
let entry_signals: Vec<&[bool]> =
|
||||||
.iter()
|
instruments.iter().map(|(_, s)| s.entries.as_slice()).collect();
|
||||||
.map(|(_, s)| s.entries.as_slice())
|
let exit_signals: Vec<&[bool]> =
|
||||||
.collect();
|
instruments.iter().map(|(_, s)| s.exits.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_entries = self.synchronizer.sync_entries(&entry_signals);
|
||||||
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
|
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
|
||||||
|
|
||||||
// Clean signals
|
// Clean signals
|
||||||
let (clean_entries, clean_exits) = self
|
let (clean_entries, clean_exits) =
|
||||||
.signal_processor
|
self.signal_processor.clean_signals(&synced_entries, &synced_exits);
|
||||||
.clean_signals(&synced_entries, &synced_exits);
|
|
||||||
|
|
||||||
// Initialize state
|
// Initialize state
|
||||||
let mut cash = self.config.base.initial_capital;
|
let mut cash = self.config.base.initial_capital;
|
||||||
@@ -136,8 +127,7 @@ impl BasketBacktest {
|
|||||||
if let Some(pos) = positions[inst_idx].take() {
|
if let Some(pos) = positions[inst_idx].take() {
|
||||||
let exit_price = ohlcv.close[i];
|
let exit_price = ohlcv.close[i];
|
||||||
let fees =
|
let fees =
|
||||||
self.fee_model
|
self.fee_model.calculate(exit_price, pos.size, signals.direction);
|
||||||
.calculate(exit_price, pos.size, signals.direction);
|
|
||||||
|
|
||||||
let pnl = (exit_price - pos.entry_price)
|
let pnl = (exit_price - pos.entry_price)
|
||||||
* pos.size
|
* pos.size
|
||||||
@@ -145,11 +135,8 @@ impl BasketBacktest {
|
|||||||
- fees;
|
- fees;
|
||||||
|
|
||||||
let cost_basis = pos.entry_price * pos.size;
|
let cost_basis = pos.entry_price * pos.size;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct =
|
||||||
pnl / cost_basis * 100.0
|
if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
cash += exit_price * pos.size - fees;
|
cash += exit_price * pos.size - fees;
|
||||||
|
|
||||||
@@ -188,16 +175,11 @@ impl BasketBacktest {
|
|||||||
let size = sizes[inst_idx];
|
let size = sizes[inst_idx];
|
||||||
if size > 0.0 {
|
if size > 0.0 {
|
||||||
let entry_price = ohlcv.close[i];
|
let entry_price = ohlcv.close[i];
|
||||||
let fees = self
|
let fees = self.fee_model.calculate(entry_price, size, signals.direction);
|
||||||
.fee_model
|
|
||||||
.calculate(entry_price, size, signals.direction);
|
|
||||||
cash -= entry_price * size + fees;
|
cash -= entry_price * size + fees;
|
||||||
|
|
||||||
positions[inst_idx] = Some(PositionState {
|
positions[inst_idx] =
|
||||||
entry_idx: i,
|
Some(PositionState { entry_idx: i, entry_price, size });
|
||||||
entry_price,
|
|
||||||
size,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,20 +211,14 @@ impl BasketBacktest {
|
|||||||
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
|
||||||
if let Some(pos) = positions[inst_idx].take() {
|
if let Some(pos) = positions[inst_idx].take() {
|
||||||
let exit_price = ohlcv.close[last_idx];
|
let exit_price = ohlcv.close[last_idx];
|
||||||
let fees = self
|
let fees = self.fee_model.calculate(exit_price, pos.size, signals.direction);
|
||||||
.fee_model
|
|
||||||
.calculate(exit_price, pos.size, signals.direction);
|
|
||||||
|
|
||||||
let pnl =
|
let pnl =
|
||||||
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
|
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
|
||||||
- fees;
|
- fees;
|
||||||
|
|
||||||
let cost_basis = pos.entry_price * pos.size;
|
let cost_basis = pos.entry_price * pos.size;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
trades.push(Trade {
|
trades.push(Trade {
|
||||||
id: trade_counter,
|
id: trade_counter,
|
||||||
@@ -319,11 +295,7 @@ impl BasketBacktest {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||||
let gross_loss: f64 = trades
|
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl < 0.0)
|
|
||||||
.map(|t| t.pnl.abs())
|
|
||||||
.sum();
|
|
||||||
let profit_factor = if gross_loss > 0.0 {
|
let profit_factor = if gross_loss > 0.0 {
|
||||||
gross_profit / gross_loss
|
gross_profit / gross_loss
|
||||||
} else if gross_profit > 0.0 {
|
} else if gross_profit > 0.0 {
|
||||||
@@ -386,6 +358,7 @@ struct PositionState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::core::Direction;
|
||||||
|
|
||||||
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
|
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
|
||||||
let n = 20;
|
let n = 20;
|
||||||
@@ -454,10 +427,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sync_mode_all() {
|
fn test_sync_mode_all() {
|
||||||
let config = BasketConfig {
|
let config = BasketConfig { sync_mode: SyncMode::All, ..Default::default() };
|
||||||
sync_mode: SyncMode::All,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = BasketBacktest::new(config);
|
let backtest = BasketBacktest::new(config);
|
||||||
let instruments = sample_instruments();
|
let instruments = sample_instruments();
|
||||||
|
|
||||||
|
|||||||
+14
-48
@@ -66,10 +66,7 @@ pub struct MultiStrategyBacktest {
|
|||||||
impl MultiStrategyBacktest {
|
impl MultiStrategyBacktest {
|
||||||
/// Create a new multi-strategy backtest.
|
/// Create a new multi-strategy backtest.
|
||||||
pub fn new(config: MultiStrategyConfig) -> Self {
|
pub fn new(config: MultiStrategyConfig) -> Self {
|
||||||
Self {
|
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||||
fee_model: FeeModel::percentage(config.base.fees),
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run multi-strategy backtest.
|
/// Run multi-strategy backtest.
|
||||||
@@ -87,11 +84,7 @@ impl MultiStrategyBacktest {
|
|||||||
|
|
||||||
let n = ohlcv.len();
|
let n = ohlcv.len();
|
||||||
for signals in strategies {
|
for signals in strategies {
|
||||||
assert_eq!(
|
assert_eq!(signals.len(), n, "All strategies must have same length as OHLCV");
|
||||||
signals.len(),
|
|
||||||
n,
|
|
||||||
"All strategies must have same length as OHLCV"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match self.config.combine_mode {
|
match self.config.combine_mode {
|
||||||
@@ -113,10 +106,8 @@ impl MultiStrategyBacktest {
|
|||||||
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
|
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
|
||||||
|
|
||||||
for (strat_idx, signals) in strategies.iter().enumerate() {
|
for (strat_idx, signals) in strategies.iter().enumerate() {
|
||||||
let single_config = BacktestConfig {
|
let single_config =
|
||||||
initial_capital: capital_per,
|
BacktestConfig { initial_capital: capital_per, ..self.config.base.clone() };
|
||||||
..self.config.base.clone()
|
|
||||||
};
|
|
||||||
let single = crate::strategies::single::SingleBacktest::new(single_config);
|
let single = crate::strategies::single::SingleBacktest::new(single_config);
|
||||||
let result = single.run(ohlcv, signals);
|
let result = single.run(ohlcv, signals);
|
||||||
|
|
||||||
@@ -168,13 +159,7 @@ impl MultiStrategyBacktest {
|
|||||||
self.config.base.initial_capital,
|
self.config.base.initial_capital,
|
||||||
);
|
);
|
||||||
|
|
||||||
BacktestResult::new(
|
BacktestResult::new(metrics, combined_equity, drawdown_curve, all_trades, returns)
|
||||||
metrics,
|
|
||||||
combined_equity,
|
|
||||||
drawdown_curve,
|
|
||||||
all_trades,
|
|
||||||
returns,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run strategies with combined signals.
|
/// Run strategies with combined signals.
|
||||||
@@ -200,19 +185,11 @@ impl MultiStrategyBacktest {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, s)| s.entries[i])
|
.filter(|(_, s)| s.entries[i])
|
||||||
.map(|(idx, _)| {
|
.map(|(idx, _)| {
|
||||||
self.config
|
self.config.strategy_weights.get(idx).copied().unwrap_or(1.0)
|
||||||
.strategy_weights
|
|
||||||
.get(idx)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(1.0)
|
|
||||||
})
|
})
|
||||||
.sum();
|
.sum();
|
||||||
let total_weight: f64 = self
|
let total_weight: f64 =
|
||||||
.config
|
self.config.strategy_weights.iter().sum::<f64>().max(n_strategies as f64);
|
||||||
.strategy_weights
|
|
||||||
.iter()
|
|
||||||
.sum::<f64>()
|
|
||||||
.max(n_strategies as f64);
|
|
||||||
weighted_sum / total_weight > 0.5
|
weighted_sum / total_weight > 0.5
|
||||||
}
|
}
|
||||||
CombineMode::Independent => unreachable!(),
|
CombineMode::Independent => unreachable!(),
|
||||||
@@ -266,11 +243,7 @@ impl MultiStrategyBacktest {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||||
let gross_loss: f64 = trades
|
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl < 0.0)
|
|
||||||
.map(|t| t.pnl.abs())
|
|
||||||
.sum();
|
|
||||||
let profit_factor = if gross_loss > 0.0 {
|
let profit_factor = if gross_loss > 0.0 {
|
||||||
gross_profit / gross_loss
|
gross_profit / gross_loss
|
||||||
} else if gross_profit > 0.0 {
|
} else if gross_profit > 0.0 {
|
||||||
@@ -319,6 +292,7 @@ impl MultiStrategyBacktest {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::core::Direction;
|
||||||
|
|
||||||
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
|
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
|
||||||
let n = 20;
|
let n = 20;
|
||||||
@@ -367,10 +341,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multi_any_mode() {
|
fn test_multi_any_mode() {
|
||||||
let config = MultiStrategyConfig {
|
let config = MultiStrategyConfig { combine_mode: CombineMode::Any, ..Default::default() };
|
||||||
combine_mode: CombineMode::Any,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = MultiStrategyBacktest::new(config);
|
let backtest = MultiStrategyBacktest::new(config);
|
||||||
let (ohlcv, strategies) = sample_strategies();
|
let (ohlcv, strategies) = sample_strategies();
|
||||||
|
|
||||||
@@ -382,10 +353,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multi_all_mode() {
|
fn test_multi_all_mode() {
|
||||||
let config = MultiStrategyConfig {
|
let config = MultiStrategyConfig { combine_mode: CombineMode::All, ..Default::default() };
|
||||||
combine_mode: CombineMode::All,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = MultiStrategyBacktest::new(config);
|
let backtest = MultiStrategyBacktest::new(config);
|
||||||
let (ohlcv, strategies) = sample_strategies();
|
let (ohlcv, strategies) = sample_strategies();
|
||||||
|
|
||||||
@@ -397,10 +365,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multi_independent_mode() {
|
fn test_multi_independent_mode() {
|
||||||
let config = MultiStrategyConfig {
|
let config =
|
||||||
combine_mode: CombineMode::Independent,
|
MultiStrategyConfig { combine_mode: CombineMode::Independent, ..Default::default() };
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = MultiStrategyBacktest::new(config);
|
let backtest = MultiStrategyBacktest::new(config);
|
||||||
let (ohlcv, strategies) = sample_strategies();
|
let (ohlcv, strategies) = sample_strategies();
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,7 @@ pub struct OptionsBacktest {
|
|||||||
impl OptionsBacktest {
|
impl OptionsBacktest {
|
||||||
/// Create a new options backtest.
|
/// Create a new options backtest.
|
||||||
pub fn new(config: OptionsConfig) -> Self {
|
pub fn new(config: OptionsConfig) -> Self {
|
||||||
Self {
|
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||||
fee_model: FeeModel::percentage(config.base.fees),
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run options backtest.
|
/// Run options backtest.
|
||||||
@@ -158,11 +155,7 @@ impl OptionsBacktest {
|
|||||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
||||||
let cost_basis =
|
let cost_basis =
|
||||||
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
|
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
|
||||||
|
|
||||||
@@ -196,8 +189,7 @@ impl OptionsBacktest {
|
|||||||
if contracts > 0 {
|
if contracts > 0 {
|
||||||
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
|
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
|
||||||
let fees =
|
let fees =
|
||||||
self.fee_model
|
self.fee_model.calculate(option_price, contracts as f64, signals.direction);
|
||||||
.calculate(option_price, contracts as f64, signals.direction);
|
|
||||||
|
|
||||||
cash -= entry_cost + fees;
|
cash -= entry_cost + fees;
|
||||||
|
|
||||||
@@ -237,16 +229,11 @@ impl OptionsBacktest {
|
|||||||
let last_idx = n - 1;
|
let last_idx = n - 1;
|
||||||
let exit_price = option_prices[last_idx];
|
let exit_price = option_prices[last_idx];
|
||||||
let fees =
|
let fees =
|
||||||
self.fee_model
|
self.fee_model.calculate(exit_price, pos.contracts as f64, signals.direction);
|
||||||
.calculate(exit_price, pos.contracts as f64, signals.direction);
|
|
||||||
|
|
||||||
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
|
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 cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
trades.push(Trade {
|
trades.push(Trade {
|
||||||
id: trade_counter,
|
id: trade_counter,
|
||||||
@@ -354,11 +341,7 @@ impl OptionsBacktest {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||||
let gross_loss: f64 = trades
|
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl < 0.0)
|
|
||||||
.map(|t| t.pnl.abs())
|
|
||||||
.sum();
|
|
||||||
let profit_factor = if gross_loss > 0.0 {
|
let profit_factor = if gross_loss > 0.0 {
|
||||||
gross_profit / gross_loss
|
gross_profit / gross_loss
|
||||||
} else if gross_profit > 0.0 {
|
} else if gross_profit > 0.0 {
|
||||||
@@ -436,11 +419,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_position_sizing_percent() {
|
fn test_position_sizing_percent() {
|
||||||
let config = OptionsConfig {
|
let config =
|
||||||
size_type: SizeType::Percent(0.5),
|
OptionsConfig { size_type: SizeType::Percent(0.5), lot_size: 50, ..Default::default() };
|
||||||
lot_size: 50,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = OptionsBacktest::new(config);
|
let backtest = OptionsBacktest::new(config);
|
||||||
|
|
||||||
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
|
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
|
||||||
|
|||||||
+10
-38
@@ -54,10 +54,7 @@ pub struct PairsBacktest {
|
|||||||
impl PairsBacktest {
|
impl PairsBacktest {
|
||||||
/// Create a new pairs backtest.
|
/// Create a new pairs backtest.
|
||||||
pub fn new(config: PairsConfig) -> Self {
|
pub fn new(config: PairsConfig) -> Self {
|
||||||
Self {
|
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||||
fee_model: FeeModel::percentage(config.base.fees),
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run pairs trading backtest.
|
/// Run pairs trading backtest.
|
||||||
@@ -114,11 +111,7 @@ impl PairsBacktest {
|
|||||||
if let Some(pos) = position.take() {
|
if let Some(pos) = position.take() {
|
||||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return capital
|
// Return capital
|
||||||
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
|
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
|
||||||
@@ -240,11 +233,7 @@ impl PairsBacktest {
|
|||||||
|
|
||||||
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
|
||||||
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
let cost_basis = pos.leg1_cost + pos.leg2_cost;
|
||||||
let return_pct = if cost_basis > 0.0 {
|
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||||
pnl / cost_basis * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
trades.push(Trade {
|
trades.push(Trade {
|
||||||
id: trade_counter,
|
id: trade_counter,
|
||||||
@@ -281,11 +270,7 @@ impl PairsBacktest {
|
|||||||
|
|
||||||
let sum_x: f64 = leg2_prices.iter().sum();
|
let sum_x: f64 = leg2_prices.iter().sum();
|
||||||
let sum_y: f64 = leg1_prices.iter().sum();
|
let sum_y: f64 = leg1_prices.iter().sum();
|
||||||
let sum_xy: f64 = leg1_prices
|
let sum_xy: f64 = leg1_prices.iter().zip(leg2_prices.iter()).map(|(y, x)| x * y).sum();
|
||||||
.iter()
|
|
||||||
.zip(leg2_prices.iter())
|
|
||||||
.map(|(y, x)| x * y)
|
|
||||||
.sum();
|
|
||||||
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
|
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
|
||||||
|
|
||||||
let denominator = n * sum_x2 - sum_x * sum_x;
|
let denominator = n * sum_x2 - sum_x * sum_x;
|
||||||
@@ -313,11 +298,8 @@ impl PairsBacktest {
|
|||||||
* position.leg2_direction.multiplier();
|
* position.leg2_direction.multiplier();
|
||||||
|
|
||||||
let exit_fees =
|
let exit_fees =
|
||||||
self.fee_model
|
self.fee_model.calculate(leg1_price, position.leg1_size, position.leg1_direction)
|
||||||
.calculate(leg1_price, position.leg1_size, position.leg1_direction)
|
+ self.fee_model.calculate(leg2_price, position.leg2_size, position.leg2_direction);
|
||||||
+ self
|
|
||||||
.fee_model
|
|
||||||
.calculate(leg2_price, position.leg2_size, position.leg2_direction);
|
|
||||||
|
|
||||||
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
|
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
|
||||||
|
|
||||||
@@ -340,10 +322,8 @@ impl PairsBacktest {
|
|||||||
|
|
||||||
// For pairs, count trade pairs (every 2 trades = 1 round trip)
|
// For pairs, count trade pairs (every 2 trades = 1 round trip)
|
||||||
let total_trades = trades.len() / 2;
|
let total_trades = trades.len() / 2;
|
||||||
let winning_trades = trades
|
let winning_trades =
|
||||||
.chunks(2)
|
trades.chunks(2).filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0).count();
|
||||||
.filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0)
|
|
||||||
.count();
|
|
||||||
let losing_trades = total_trades.saturating_sub(winning_trades);
|
let losing_trades = total_trades.saturating_sub(winning_trades);
|
||||||
|
|
||||||
let win_rate_pct = if total_trades > 0 {
|
let win_rate_pct = if total_trades > 0 {
|
||||||
@@ -353,11 +333,7 @@ impl PairsBacktest {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
|
||||||
let gross_loss: f64 = trades
|
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
|
||||||
.iter()
|
|
||||||
.filter(|t| t.pnl < 0.0)
|
|
||||||
.map(|t| t.pnl.abs())
|
|
||||||
.sum();
|
|
||||||
let profit_factor = if gross_loss > 0.0 {
|
let profit_factor = if gross_loss > 0.0 {
|
||||||
gross_profit / gross_loss
|
gross_profit / gross_loss
|
||||||
} else if gross_profit > 0.0 {
|
} else if gross_profit > 0.0 {
|
||||||
@@ -463,11 +439,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hedge_ratio_calculation() {
|
fn test_hedge_ratio_calculation() {
|
||||||
let config = PairsConfig {
|
let config = PairsConfig { dynamic_hedge: true, hedge_lookback: 5, ..Default::default() };
|
||||||
dynamic_hedge: true,
|
|
||||||
hedge_lookback: 5,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let backtest = PairsBacktest::new(config);
|
let backtest = PairsBacktest::new(config);
|
||||||
|
|
||||||
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
|
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ pub struct SingleBacktest {
|
|||||||
impl SingleBacktest {
|
impl SingleBacktest {
|
||||||
/// Create a new single instrument backtest.
|
/// Create a new single instrument backtest.
|
||||||
pub fn new(config: BacktestConfig) -> Self {
|
pub fn new(config: BacktestConfig) -> Self {
|
||||||
Self {
|
Self { engine: PortfolioEngine::new(config) }
|
||||||
engine: PortfolioEngine::new(config),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the backtest.
|
/// Run the backtest.
|
||||||
@@ -181,12 +179,8 @@ mod tests {
|
|||||||
let low: 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 volume = vec![1000.0; 10];
|
||||||
|
|
||||||
let entries = vec![
|
let entries = vec![false, true, false, false, false, false, false, false, false, false];
|
||||||
false, true, false, false, false, false, false, false, false, false,
|
let exits = vec![false, false, false, false, false, true, false, false, false, false];
|
||||||
];
|
|
||||||
let exits = vec![
|
|
||||||
false, false, false, false, false, true, false, false, false, false,
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = backtest.run_from_arrays(
|
let result = backtest.run_from_arrays(
|
||||||
×tamps,
|
×tamps,
|
||||||
|
|||||||
@@ -103,23 +103,13 @@ fn test_stochastic_range() {
|
|||||||
// %K and %D should be in [0, 100]
|
// %K and %D should be in [0, 100]
|
||||||
for (i, &k) in result.k.iter().enumerate() {
|
for (i, &k) in result.k.iter().enumerate() {
|
||||||
if !k.is_nan() {
|
if !k.is_nan() {
|
||||||
assert!(
|
assert!(k >= 0.0 && k <= 100.0, "%K at index {} is out of range: {}", i, k);
|
||||||
k >= 0.0 && k <= 100.0,
|
|
||||||
"%K at index {} is out of range: {}",
|
|
||||||
i,
|
|
||||||
k
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, &d) in result.d.iter().enumerate() {
|
for (i, &d) in result.d.iter().enumerate() {
|
||||||
if !d.is_nan() {
|
if !d.is_nan() {
|
||||||
assert!(
|
assert!(d >= 0.0 && d <= 100.0, "%D at index {} is out of range: {}", i, d);
|
||||||
d >= 0.0 && d <= 100.0,
|
|
||||||
"%D at index {} is out of range: {}",
|
|
||||||
i,
|
|
||||||
d
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,12 +237,7 @@ fn test_short_direction() {
|
|||||||
|
|
||||||
let ohlcv = OhlcvData {
|
let ohlcv = OhlcvData {
|
||||||
timestamps: (0..n as i64).collect(),
|
timestamps: (0..n as i64).collect(),
|
||||||
open: close
|
open: close.iter().skip(1).chain(std::iter::once(&close[n - 1])).cloned().collect(),
|
||||||
.iter()
|
|
||||||
.skip(1)
|
|
||||||
.chain(std::iter::once(&close[n - 1]))
|
|
||||||
.cloned()
|
|
||||||
.collect(),
|
|
||||||
high: close.iter().map(|c| c + 1.0).collect(),
|
high: close.iter().map(|c| c + 1.0).collect(),
|
||||||
low: close.iter().map(|c| c - 1.0).collect(),
|
low: close.iter().map(|c| c - 1.0).collect(),
|
||||||
close: close.clone(),
|
close: close.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user