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
|
||||
|
||||
jobs:
|
||||
test:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-action@stable
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --all-features
|
||||
|
||||
- name: Run Rust clippy
|
||||
run: cargo clippy --all-features -- -D warnings
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Check Rust formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Run Rust clippy
|
||||
run: cargo clippy --all-features
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
# Note: imports_granularity and group_imports require nightly Rust
|
||||
# imports_granularity = "Module"
|
||||
# group_imports = "StdExternalCrate"
|
||||
+5
-16
@@ -49,38 +49,27 @@ impl RaptorError {
|
||||
|
||||
/// Create an invalid parameter error.
|
||||
pub fn invalid_parameter(message: impl Into<String>) -> Self {
|
||||
Self::InvalidParameter {
|
||||
message: message.into(),
|
||||
}
|
||||
Self::InvalidParameter { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create an insufficient data error.
|
||||
pub fn insufficient_data(required: usize, available: usize) -> Self {
|
||||
Self::InsufficientData {
|
||||
required,
|
||||
available,
|
||||
}
|
||||
Self::InsufficientData { required, available }
|
||||
}
|
||||
|
||||
/// Create an invalid config error.
|
||||
pub fn invalid_config(message: impl Into<String>) -> Self {
|
||||
Self::InvalidConfig {
|
||||
message: message.into(),
|
||||
}
|
||||
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(),
|
||||
}
|
||||
Self::DivisionByZero { context: context.into() }
|
||||
}
|
||||
|
||||
/// Create an empty data error.
|
||||
pub fn empty_data(context: impl Into<String>) -> Self {
|
||||
Self::EmptyData {
|
||||
context: context.into(),
|
||||
}
|
||||
Self::EmptyData { context: context.into() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-59
@@ -77,20 +77,14 @@ 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],
|
||||
}
|
||||
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],
|
||||
}
|
||||
Self { timestamps: (0..len as i64).collect(), values: vec![f64::NAN; len] }
|
||||
}
|
||||
|
||||
/// Calculate sum of all values.
|
||||
@@ -124,20 +118,12 @@ impl TimeSeries<f64> {
|
||||
|
||||
/// Get minimum value.
|
||||
pub fn min(&self) -> f64 {
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.copied()
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
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)
|
||||
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).
|
||||
@@ -161,10 +147,7 @@ impl TimeSeries<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
Self { timestamps: self.timestamps.clone(), values: result }
|
||||
}
|
||||
|
||||
/// Calculate difference from previous value.
|
||||
@@ -175,10 +158,7 @@ impl TimeSeries<f64> {
|
||||
result[i] = self.values[i] - self.values[i - 1];
|
||||
}
|
||||
}
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
Self { timestamps: self.timestamps.clone(), values: result }
|
||||
}
|
||||
|
||||
/// 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];
|
||||
}
|
||||
}
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
Self { timestamps: self.timestamps.clone(), values: result }
|
||||
}
|
||||
|
||||
/// Apply rolling window function.
|
||||
@@ -203,10 +180,7 @@ impl TimeSeries<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,
|
||||
};
|
||||
return Self { timestamps: self.timestamps.clone(), values: result };
|
||||
}
|
||||
|
||||
for i in (window - 1)..self.values.len() {
|
||||
@@ -214,10 +188,7 @@ impl TimeSeries<f64> {
|
||||
result[i] = f(slice);
|
||||
}
|
||||
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: result,
|
||||
}
|
||||
Self { timestamps: self.timestamps.clone(), values: result }
|
||||
}
|
||||
|
||||
/// Calculate rolling sum.
|
||||
@@ -227,9 +198,7 @@ impl TimeSeries<f64> {
|
||||
|
||||
/// Calculate rolling mean.
|
||||
pub fn rolling_mean(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
slice.iter().sum::<f64>() / slice.len() as f64
|
||||
})
|
||||
self.rolling(window, |slice| slice.iter().sum::<f64>() / slice.len() as f64)
|
||||
}
|
||||
|
||||
/// Calculate rolling standard deviation.
|
||||
@@ -244,16 +213,12 @@ impl TimeSeries<f64> {
|
||||
|
||||
/// Calculate rolling maximum.
|
||||
pub fn rolling_max(&self, window: usize) -> Self {
|
||||
self.rolling(window, |slice| {
|
||||
slice.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||||
})
|
||||
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)
|
||||
})
|
||||
self.rolling(window, |slice| slice.iter().copied().fold(f64::INFINITY, f64::min))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,12 +242,7 @@ impl TimeSeries<bool> {
|
||||
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(),
|
||||
values: self.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());
|
||||
Self {
|
||||
timestamps: self.timestamps.clone(),
|
||||
values: self
|
||||
.values
|
||||
.iter()
|
||||
.zip(other.values.iter())
|
||||
.map(|(&a, &b)| a || b)
|
||||
.collect(),
|
||||
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a || b).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-23
@@ -73,14 +73,7 @@ impl OhlcvData {
|
||||
close: Vec<Price>,
|
||||
volume: Vec<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timestamps,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
}
|
||||
Self { timestamps, open, high, low, close, volume }
|
||||
}
|
||||
|
||||
/// Get the number of bars.
|
||||
@@ -137,14 +130,7 @@ impl CompiledSignals {
|
||||
direction: Direction,
|
||||
weight: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
symbol,
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction,
|
||||
weight,
|
||||
}
|
||||
Self { symbol, entries, exits, position_sizes: None, direction, weight }
|
||||
}
|
||||
|
||||
/// Set position sizes.
|
||||
@@ -375,13 +361,7 @@ impl BacktestResult {
|
||||
trades: Vec<Trade>,
|
||||
returns: Vec<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
metrics,
|
||||
equity_curve,
|
||||
drawdown_curve,
|
||||
trades,
|
||||
returns,
|
||||
}
|
||||
Self { metrics, equity_curve, drawdown_curve, trades, returns }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,10 +92,7 @@ pub struct BrokerFees;
|
||||
impl BrokerFees {
|
||||
/// Interactive Brokers tiered pricing (approximate).
|
||||
pub fn interactive_brokers() -> FeeModel {
|
||||
FeeModel::Custom {
|
||||
base: 1.0,
|
||||
per_share: 0.005,
|
||||
}
|
||||
FeeModel::Custom { base: 1.0, per_share: 0.005 }
|
||||
}
|
||||
|
||||
/// Zero commission broker (like Robinhood).
|
||||
|
||||
+4
-23
@@ -121,31 +121,19 @@ pub struct FillModel {
|
||||
|
||||
impl Default for FillModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fill_price: FillPrice::Close,
|
||||
delay_to_next_bar: false,
|
||||
fill_ratio: 1.0,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
Self { fill_price: FillPrice::Open, delay_to_next_bar: true, fill_ratio: 1.0 }
|
||||
}
|
||||
|
||||
/// Set partial fill ratio.
|
||||
@@ -306,14 +294,7 @@ 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,
|
||||
}
|
||||
OhlcvBar { timestamp: 0, open: 100.0, high: 105.0, low: 95.0, close: 102.0, volume: 1000.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,10 +36,7 @@ impl SlippageModel {
|
||||
|
||||
/// Create a volume-based slippage model.
|
||||
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
|
||||
SlippageModel::VolumeBased {
|
||||
base,
|
||||
volume_factor,
|
||||
}
|
||||
SlippageModel::VolumeBased { base, volume_factor }
|
||||
}
|
||||
|
||||
/// Calculate slippage for a trade.
|
||||
@@ -66,10 +63,7 @@ impl SlippageModel {
|
||||
SlippageModel::None => 0.0,
|
||||
SlippageModel::Percentage(rate) => price * rate,
|
||||
SlippageModel::Fixed(points) => *points,
|
||||
SlippageModel::VolumeBased {
|
||||
base,
|
||||
volume_factor,
|
||||
} => {
|
||||
SlippageModel::VolumeBased { base, volume_factor } => {
|
||||
if let Some(vol) = volume {
|
||||
if vol > 0.0 {
|
||||
base * (1.0 / (1.0 + vol * volume_factor))
|
||||
@@ -131,11 +125,7 @@ pub struct MarketImpact {
|
||||
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,
|
||||
}
|
||||
Self { temporary_impact: temporary, permanent_impact: permanent, avg_daily_volume: adv }
|
||||
}
|
||||
|
||||
/// Calculate market impact for an order.
|
||||
|
||||
@@ -100,9 +100,7 @@ pub fn macd(
|
||||
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",
|
||||
));
|
||||
return Err(RaptorError::invalid_parameter("MACD fast period must be < slow period"));
|
||||
}
|
||||
|
||||
let n = data.len();
|
||||
@@ -111,11 +109,7 @@ pub fn macd(
|
||||
let mut histogram = vec![f64::NAN; n];
|
||||
|
||||
if slow_period > n {
|
||||
return Ok(MacdResult {
|
||||
macd_line,
|
||||
signal_line,
|
||||
histogram,
|
||||
});
|
||||
return Ok(MacdResult { macd_line, signal_line, histogram });
|
||||
}
|
||||
|
||||
// Calculate fast and slow EMAs
|
||||
@@ -163,11 +157,7 @@ pub fn macd(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MacdResult {
|
||||
macd_line,
|
||||
signal_line,
|
||||
histogram,
|
||||
})
|
||||
Ok(MacdResult { macd_line, signal_line, histogram })
|
||||
}
|
||||
|
||||
/// Stochastic oscillator result.
|
||||
@@ -202,9 +192,7 @@ pub fn stochastic(
|
||||
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",
|
||||
));
|
||||
return Err(RaptorError::invalid_parameter("Stochastic periods must be > 0"));
|
||||
}
|
||||
|
||||
let mut k = vec![f64::NAN; n];
|
||||
|
||||
@@ -152,11 +152,7 @@ pub fn directional_movement(
|
||||
let mut adx_values = vec![f64::NAN; n];
|
||||
|
||||
if 2 * period > n {
|
||||
return Ok(DirectionalIndexResult {
|
||||
plus_di,
|
||||
minus_di,
|
||||
adx: adx_values,
|
||||
});
|
||||
return Ok(DirectionalIndexResult { plus_di, minus_di, adx: adx_values });
|
||||
}
|
||||
|
||||
// Calculate directional movement
|
||||
@@ -217,11 +213,7 @@ pub fn directional_movement(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DirectionalIndexResult {
|
||||
plus_di,
|
||||
minus_di,
|
||||
adx: adx_values,
|
||||
})
|
||||
Ok(DirectionalIndexResult { plus_di, minus_di, adx: adx_values })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+3
-11
@@ -131,19 +131,14 @@ pub fn supertrend(
|
||||
return Err(RaptorError::length_mismatch(n, high.len()));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(RaptorError::invalid_parameter(
|
||||
"Supertrend period must be > 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,
|
||||
});
|
||||
return Ok(SupertrendResult { supertrend, direction });
|
||||
}
|
||||
|
||||
// Calculate ATR
|
||||
@@ -238,10 +233,7 @@ pub fn supertrend(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SupertrendResult {
|
||||
supertrend,
|
||||
direction,
|
||||
})
|
||||
Ok(SupertrendResult { supertrend, direction })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -88,14 +88,10 @@ pub struct BollingerBandsResult {
|
||||
/// 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",
|
||||
));
|
||||
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",
|
||||
));
|
||||
return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0"));
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
if period > n {
|
||||
return Ok(BollingerBandsResult {
|
||||
middle,
|
||||
upper,
|
||||
lower,
|
||||
bandwidth,
|
||||
percent_b,
|
||||
});
|
||||
return Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b });
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate standard deviation using population variance
|
||||
let variance: f64 = data[start..=i]
|
||||
.iter()
|
||||
.map(|x| (x - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ period as f64;
|
||||
let variance: f64 =
|
||||
data[start..=i].iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||
let std = variance.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 {
|
||||
middle,
|
||||
upper,
|
||||
lower,
|
||||
bandwidth,
|
||||
percent_b,
|
||||
})
|
||||
Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b })
|
||||
}
|
||||
|
||||
/// Keltner Channels (ATR-based bands).
|
||||
@@ -227,9 +208,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bollinger_bands() {
|
||||
let data: Vec<f64> = (1..=30)
|
||||
.map(|x| x as f64 + (x as f64 * 0.1).sin())
|
||||
.collect();
|
||||
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();
|
||||
|
||||
|
||||
@@ -256,11 +256,7 @@ pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> {
|
||||
/// 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
|
||||
};
|
||||
return if total_return > 0.0 { f64::INFINITY } else { 0.0 };
|
||||
}
|
||||
total_return / max_drawdown
|
||||
}
|
||||
|
||||
@@ -206,11 +206,7 @@ impl StreamingMetrics {
|
||||
/// 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
|
||||
};
|
||||
return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 };
|
||||
}
|
||||
self.sum_positive / self.sum_negative.abs()
|
||||
}
|
||||
|
||||
@@ -117,11 +117,9 @@ impl TradeStatistics {
|
||||
|
||||
// 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;
|
||||
stats.avg_holding_period =
|
||||
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>()
|
||||
/ stats.total_trades as f64;
|
||||
}
|
||||
|
||||
// Consecutive wins/losses
|
||||
@@ -231,22 +229,13 @@ pub fn stats_by_exit_reason(
|
||||
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 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();
|
||||
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),
|
||||
)
|
||||
(TradeStatistics::from_trades(&long_trades), TradeStatistics::from_trades(&short_trades))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -151,18 +151,13 @@ impl CapitalAllocator {
|
||||
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]),
|
||||
_ => 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()
|
||||
instrument_weights.iter().map(|w| w / total_weight).collect()
|
||||
} else {
|
||||
vec![1.0 / n as f64; n]
|
||||
};
|
||||
|
||||
+33
-98
@@ -37,11 +37,7 @@ 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
|
||||
};
|
||||
let fill_price = if config.upon_bar_close { FillPrice::Close } else { FillPrice::Open };
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -77,9 +73,8 @@ impl PortfolioEngine {
|
||||
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);
|
||||
let (entries, exits) =
|
||||
self.signal_processor.clean_signals(&signals.entries, &signals.exits);
|
||||
|
||||
// Initialize state
|
||||
let mut position = PositionManager::new(signals.symbol.clone());
|
||||
@@ -224,8 +219,7 @@ impl PortfolioEngine {
|
||||
if size > 0.0 {
|
||||
// Calculate entry fees
|
||||
let entry_fees =
|
||||
self.fee_model
|
||||
.calculate(adjusted_price, size, signals.direction);
|
||||
self.fee_model.calculate(adjusted_price, size, signals.direction);
|
||||
|
||||
// Calculate stop and target prices
|
||||
let (stop_price, target_price) = self.calculate_stop_target(
|
||||
@@ -253,11 +247,8 @@ impl PortfolioEngine {
|
||||
}
|
||||
|
||||
// Calculate equity
|
||||
let position_value = if position.is_in_position() {
|
||||
close * position.position.size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let position_value =
|
||||
if position.is_in_position() { close * position.position.size } else { 0.0 };
|
||||
let equity = cash + position_value;
|
||||
equity_curve[i] = equity;
|
||||
|
||||
@@ -295,13 +286,8 @@ impl PortfolioEngine {
|
||||
}
|
||||
|
||||
// Calculate final metrics
|
||||
let metrics = self.calculate_metrics(
|
||||
&equity_curve,
|
||||
&drawdown_curve,
|
||||
&returns,
|
||||
&trades,
|
||||
&streaming,
|
||||
);
|
||||
let metrics =
|
||||
self.calculate_metrics(&equity_curve, &drawdown_curve, &returns, &trades, &streaming);
|
||||
|
||||
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
|
||||
}
|
||||
@@ -396,10 +382,8 @@ impl PortfolioEngine {
|
||||
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_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
|
||||
@@ -410,10 +394,8 @@ impl PortfolioEngine {
|
||||
.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 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();
|
||||
@@ -428,37 +410,18 @@ impl PortfolioEngine {
|
||||
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 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
|
||||
};
|
||||
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 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 {
|
||||
@@ -498,22 +461,14 @@ impl PortfolioEngine {
|
||||
};
|
||||
|
||||
let avg_win_pct = if winning_trades > 0 {
|
||||
closed_trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl > 0.0)
|
||||
.map(|t| t.return_pct)
|
||||
.sum::<f64>()
|
||||
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>()
|
||||
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
|
||||
/ losing_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
@@ -547,11 +502,7 @@ impl PortfolioEngine {
|
||||
|
||||
// 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
|
||||
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>() / total_trades as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -574,11 +525,8 @@ impl PortfolioEngine {
|
||||
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 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 {
|
||||
@@ -686,27 +634,18 @@ impl PortfolioEngine {
|
||||
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 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
|
||||
};
|
||||
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_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
|
||||
@@ -726,11 +665,7 @@ impl PortfolioEngine {
|
||||
// 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 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
|
||||
|
||||
+11
-30
@@ -16,11 +16,7 @@ pub struct PositionManager {
|
||||
impl PositionManager {
|
||||
/// Create a new position manager.
|
||||
pub fn new(symbol: String) -> Self {
|
||||
Self {
|
||||
position: Position::new(),
|
||||
trade_counter: 0,
|
||||
symbol,
|
||||
}
|
||||
Self { position: Position::new(), trade_counter: 0, symbol }
|
||||
}
|
||||
|
||||
/// Check if currently in a position.
|
||||
@@ -67,15 +63,7 @@ impl PositionManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.position.open(
|
||||
idx,
|
||||
price,
|
||||
size,
|
||||
direction,
|
||||
stop_price,
|
||||
target_price,
|
||||
entry_fees,
|
||||
);
|
||||
self.position.open(idx, price, size, direction, stop_price, target_price, entry_fees);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -131,11 +119,7 @@ impl PositionManager {
|
||||
|
||||
// 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
|
||||
};
|
||||
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||
|
||||
Trade {
|
||||
id: self.trade_counter,
|
||||
@@ -270,16 +254,14 @@ mod tests {
|
||||
let mut pm = PositionManager::new("TEST".to_string());
|
||||
|
||||
// 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());
|
||||
|
||||
// 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
|
||||
let trade = pm
|
||||
.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0)
|
||||
.unwrap();
|
||||
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);
|
||||
@@ -295,12 +277,10 @@ mod tests {
|
||||
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);
|
||||
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();
|
||||
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
|
||||
@@ -319,6 +299,7 @@ mod tests {
|
||||
Direction::Long,
|
||||
Some(95.0), // Stop at 95
|
||||
None,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// Check stop not hit
|
||||
@@ -332,7 +313,7 @@ mod tests {
|
||||
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);
|
||||
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);
|
||||
@@ -353,7 +334,7 @@ mod tests {
|
||||
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);
|
||||
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
|
||||
|
||||
// Price up
|
||||
let pnl = pm.unrealized_pnl(110.0);
|
||||
|
||||
+3
-17
@@ -115,12 +115,7 @@ pub struct PyStopConfig {
|
||||
impl PyStopConfig {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stop_type: "none".to_string(),
|
||||
percent: None,
|
||||
multiplier: None,
|
||||
period: None,
|
||||
}
|
||||
Self { stop_type: "none".to_string(), percent: None, multiplier: None, period: None }
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
@@ -689,13 +684,7 @@ pub fn run_multi_backtest<'py>(
|
||||
low: PyReadonlyArray1<f64>,
|
||||
close: PyReadonlyArray1<f64>,
|
||||
volume: PyReadonlyArray1<f64>,
|
||||
strategies: Vec<(
|
||||
PyReadonlyArray1<bool>,
|
||||
PyReadonlyArray1<bool>,
|
||||
i32,
|
||||
f64,
|
||||
String,
|
||||
)>,
|
||||
strategies: Vec<(PyReadonlyArray1<bool>, PyReadonlyArray1<bool>, i32, f64, String)>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
combine_mode: &str,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
@@ -819,10 +808,7 @@ pub fn stochastic<'py>(
|
||||
let c = numpy_to_vec_f64(close);
|
||||
let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period)
|
||||
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
|
||||
Ok((
|
||||
vec_to_numpy_f64(py, result.k),
|
||||
vec_to_numpy_f64(py, result.d),
|
||||
))
|
||||
Ok((vec_to_numpy_f64(py, result.k), vec_to_numpy_f64(py, result.d)))
|
||||
}
|
||||
|
||||
/// Average True Range.
|
||||
|
||||
@@ -321,10 +321,8 @@ pub fn is_highest(a: &[f64], window: usize) -> Vec<bool> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let max_in_window = a[start..=i]
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.fold(f64::NEG_INFINITY, |a, &b| a.max(b));
|
||||
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;
|
||||
}
|
||||
@@ -355,10 +353,8 @@ pub fn is_lowest(a: &[f64], window: usize) -> Vec<bool> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let min_in_window = a[start..=i]
|
||||
.iter()
|
||||
.filter(|v| !v.is_nan())
|
||||
.fold(f64::INFINITY, |a, &b| a.min(b));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ pub struct SignalProcessor {
|
||||
|
||||
impl Default for SignalProcessor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_pyramiding: false,
|
||||
max_pyramid_entries: 1,
|
||||
}
|
||||
Self { allow_pyramiding: false, max_pyramid_entries: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +52,7 @@ impl SignalProcessor {
|
||||
/// 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"
|
||||
);
|
||||
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];
|
||||
@@ -140,12 +133,7 @@ impl SignalProcessor {
|
||||
let mut clean_short_exits = vec![false; n];
|
||||
|
||||
if n == 0 {
|
||||
return (
|
||||
clean_long_entries,
|
||||
clean_long_exits,
|
||||
clean_short_entries,
|
||||
clean_short_exits,
|
||||
);
|
||||
return (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits);
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -248,11 +231,8 @@ impl SignalProcessor {
|
||||
.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();
|
||||
let exit_indices: Vec<usize> =
|
||||
exits.iter().enumerate().filter_map(|(i, &e)| if e { Some(i) } else { None }).collect();
|
||||
|
||||
(entry_indices, exit_indices)
|
||||
}
|
||||
|
||||
@@ -34,28 +34,19 @@ pub struct SignalSynchronizer {
|
||||
|
||||
impl Default for SignalSynchronizer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SyncMode::All,
|
||||
min_signals: None,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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),
|
||||
}
|
||||
Self { mode: SyncMode::Majority, min_signals: Some(min) }
|
||||
}
|
||||
|
||||
/// Synchronize entry signals from multiple instruments.
|
||||
@@ -155,15 +146,10 @@ impl SignalSynchronizer {
|
||||
return (vec![], vec![]);
|
||||
}
|
||||
|
||||
let entries: Vec<&[bool]> = compiled_signals
|
||||
.iter()
|
||||
.map(|cs| cs.entries.as_slice())
|
||||
.collect();
|
||||
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 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);
|
||||
|
||||
+1
-6
@@ -108,12 +108,7 @@ pub struct ChandelierExit {
|
||||
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,
|
||||
}
|
||||
Self { multiplier, atr, highest_high: 0.0, lowest_low: f64::MAX }
|
||||
}
|
||||
|
||||
/// Reset for new position.
|
||||
|
||||
+2
-6
@@ -13,9 +13,7 @@ pub struct FixedStop {
|
||||
impl FixedStop {
|
||||
/// Create a new fixed stop with given percentage.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
}
|
||||
Self { percent: percent.abs() }
|
||||
}
|
||||
|
||||
/// Create a 1% stop.
|
||||
@@ -66,9 +64,7 @@ pub struct FixedTarget {
|
||||
impl FixedTarget {
|
||||
/// Create a new fixed target with given percentage.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
}
|
||||
Self { percent: percent.abs() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-11
@@ -15,10 +15,7 @@ pub struct TrailingStop {
|
||||
impl TrailingStop {
|
||||
/// Create a new trailing stop.
|
||||
pub fn new(percent: f64) -> Self {
|
||||
Self {
|
||||
percent: percent.abs(),
|
||||
activation_threshold: None,
|
||||
}
|
||||
Self { percent: percent.abs(), activation_threshold: None }
|
||||
}
|
||||
|
||||
/// Create with activation threshold.
|
||||
@@ -89,9 +86,7 @@ pub struct PointTrailingStop {
|
||||
impl PointTrailingStop {
|
||||
/// Create a new point-based trailing stop.
|
||||
pub fn new(points: f64) -> Self {
|
||||
Self {
|
||||
points: points.abs(),
|
||||
}
|
||||
Self { points: points.abs() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,10 +132,7 @@ pub struct StepTrailingStop {
|
||||
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(),
|
||||
}
|
||||
Self { step_percent: step_percent.abs(), trail_percent: trail_percent.abs() }
|
||||
}
|
||||
|
||||
/// Calculate stop for a given step level.
|
||||
|
||||
+18
-48
@@ -83,31 +83,22 @@ impl BasketBacktest {
|
||||
|
||||
// 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!(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 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);
|
||||
let (clean_entries, clean_exits) =
|
||||
self.signal_processor.clean_signals(&synced_entries, &synced_exits);
|
||||
|
||||
// Initialize state
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
@@ -136,8 +127,7 @@ impl BasketBacktest {
|
||||
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);
|
||||
self.fee_model.calculate(exit_price, pos.size, signals.direction);
|
||||
|
||||
let pnl = (exit_price - pos.entry_price)
|
||||
* pos.size
|
||||
@@ -145,11 +135,8 @@ impl BasketBacktest {
|
||||
- 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
|
||||
};
|
||||
let return_pct =
|
||||
if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||
|
||||
cash += exit_price * pos.size - fees;
|
||||
|
||||
@@ -188,16 +175,11 @@ impl BasketBacktest {
|
||||
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);
|
||||
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,
|
||||
});
|
||||
positions[inst_idx] =
|
||||
Some(PositionState { entry_idx: i, entry_price, size });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,20 +211,14 @@ impl BasketBacktest {
|
||||
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 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
|
||||
};
|
||||
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||
|
||||
trades.push(Trade {
|
||||
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_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.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 {
|
||||
@@ -386,6 +358,7 @@ struct PositionState {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::Direction;
|
||||
|
||||
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
|
||||
let n = 20;
|
||||
@@ -454,10 +427,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sync_mode_all() {
|
||||
let config = BasketConfig {
|
||||
sync_mode: SyncMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let config = BasketConfig { sync_mode: SyncMode::All, ..Default::default() };
|
||||
let backtest = BasketBacktest::new(config);
|
||||
let instruments = sample_instruments();
|
||||
|
||||
|
||||
+14
-48
@@ -66,10 +66,7 @@ pub struct MultiStrategyBacktest {
|
||||
impl MultiStrategyBacktest {
|
||||
/// Create a new multi-strategy backtest.
|
||||
pub fn new(config: MultiStrategyConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||
}
|
||||
|
||||
/// Run multi-strategy backtest.
|
||||
@@ -87,11 +84,7 @@ impl MultiStrategyBacktest {
|
||||
|
||||
let n = ohlcv.len();
|
||||
for signals in strategies {
|
||||
assert_eq!(
|
||||
signals.len(),
|
||||
n,
|
||||
"All strategies must have same length as OHLCV"
|
||||
);
|
||||
assert_eq!(signals.len(), n, "All strategies must have same length as OHLCV");
|
||||
}
|
||||
|
||||
match self.config.combine_mode {
|
||||
@@ -113,10 +106,8 @@ impl MultiStrategyBacktest {
|
||||
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_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);
|
||||
|
||||
@@ -168,13 +159,7 @@ impl MultiStrategyBacktest {
|
||||
self.config.base.initial_capital,
|
||||
);
|
||||
|
||||
BacktestResult::new(
|
||||
metrics,
|
||||
combined_equity,
|
||||
drawdown_curve,
|
||||
all_trades,
|
||||
returns,
|
||||
)
|
||||
BacktestResult::new(metrics, combined_equity, drawdown_curve, all_trades, returns)
|
||||
}
|
||||
|
||||
/// Run strategies with combined signals.
|
||||
@@ -200,19 +185,11 @@ impl MultiStrategyBacktest {
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.entries[i])
|
||||
.map(|(idx, _)| {
|
||||
self.config
|
||||
.strategy_weights
|
||||
.get(idx)
|
||||
.copied()
|
||||
.unwrap_or(1.0)
|
||||
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);
|
||||
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!(),
|
||||
@@ -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_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.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 {
|
||||
@@ -319,6 +292,7 @@ impl MultiStrategyBacktest {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::Direction;
|
||||
|
||||
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
|
||||
let n = 20;
|
||||
@@ -367,10 +341,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_multi_any_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Any,
|
||||
..Default::default()
|
||||
};
|
||||
let config = MultiStrategyConfig { combine_mode: CombineMode::Any, ..Default::default() };
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
@@ -382,10 +353,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_multi_all_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::All,
|
||||
..Default::default()
|
||||
};
|
||||
let config = MultiStrategyConfig { combine_mode: CombineMode::All, ..Default::default() };
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
@@ -397,10 +365,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_multi_independent_mode() {
|
||||
let config = MultiStrategyConfig {
|
||||
combine_mode: CombineMode::Independent,
|
||||
..Default::default()
|
||||
};
|
||||
let config =
|
||||
MultiStrategyConfig { combine_mode: CombineMode::Independent, ..Default::default() };
|
||||
let backtest = MultiStrategyBacktest::new(config);
|
||||
let (ohlcv, strategies) = sample_strategies();
|
||||
|
||||
|
||||
@@ -100,10 +100,7 @@ pub struct OptionsBacktest {
|
||||
impl OptionsBacktest {
|
||||
/// Create a new options backtest.
|
||||
pub fn new(config: OptionsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||
}
|
||||
|
||||
/// Run options backtest.
|
||||
@@ -158,11 +155,7 @@ impl OptionsBacktest {
|
||||
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
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -196,8 +189,7 @@ impl OptionsBacktest {
|
||||
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);
|
||||
self.fee_model.calculate(option_price, contracts as f64, signals.direction);
|
||||
|
||||
cash -= entry_cost + fees;
|
||||
|
||||
@@ -237,16 +229,11 @@ impl OptionsBacktest {
|
||||
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);
|
||||
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
|
||||
};
|
||||
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||
|
||||
trades.push(Trade {
|
||||
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_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.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 {
|
||||
@@ -436,11 +419,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_position_sizing_percent() {
|
||||
let config = OptionsConfig {
|
||||
size_type: SizeType::Percent(0.5),
|
||||
lot_size: 50,
|
||||
..Default::default()
|
||||
};
|
||||
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
|
||||
|
||||
+10
-38
@@ -54,10 +54,7 @@ pub struct PairsBacktest {
|
||||
impl PairsBacktest {
|
||||
/// Create a new pairs backtest.
|
||||
pub fn new(config: PairsConfig) -> Self {
|
||||
Self {
|
||||
fee_model: FeeModel::percentage(config.base.fees),
|
||||
config,
|
||||
}
|
||||
Self { fee_model: FeeModel::percentage(config.base.fees), config }
|
||||
}
|
||||
|
||||
/// Run pairs trading backtest.
|
||||
@@ -114,11 +111,7 @@ impl PairsBacktest {
|
||||
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
|
||||
};
|
||||
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;
|
||||
@@ -240,11 +233,7 @@ impl PairsBacktest {
|
||||
|
||||
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
|
||||
};
|
||||
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
|
||||
|
||||
trades.push(Trade {
|
||||
id: trade_counter,
|
||||
@@ -281,11 +270,7 @@ impl PairsBacktest {
|
||||
|
||||
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_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;
|
||||
@@ -313,11 +298,8 @@ impl PairsBacktest {
|
||||
* 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);
|
||||
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;
|
||||
|
||||
@@ -340,10 +322,8 @@ impl PairsBacktest {
|
||||
|
||||
// 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 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 {
|
||||
@@ -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_loss: f64 = trades
|
||||
.iter()
|
||||
.filter(|t| t.pnl < 0.0)
|
||||
.map(|t| t.pnl.abs())
|
||||
.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 {
|
||||
@@ -463,11 +439,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_hedge_ratio_calculation() {
|
||||
let config = PairsConfig {
|
||||
dynamic_hedge: true,
|
||||
hedge_lookback: 5,
|
||||
..Default::default()
|
||||
};
|
||||
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];
|
||||
|
||||
@@ -13,9 +13,7 @@ pub struct SingleBacktest {
|
||||
impl SingleBacktest {
|
||||
/// Create a new single instrument backtest.
|
||||
pub fn new(config: BacktestConfig) -> Self {
|
||||
Self {
|
||||
engine: PortfolioEngine::new(config),
|
||||
}
|
||||
Self { engine: PortfolioEngine::new(config) }
|
||||
}
|
||||
|
||||
/// Run the backtest.
|
||||
@@ -181,12 +179,8 @@ mod tests {
|
||||
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 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(
|
||||
×tamps,
|
||||
|
||||
@@ -103,23 +103,13 @@ fn test_stochastic_range() {
|
||||
// %K and %D should be in [0, 100]
|
||||
for (i, &k) in result.k.iter().enumerate() {
|
||||
if !k.is_nan() {
|
||||
assert!(
|
||||
k >= 0.0 && k <= 100.0,
|
||||
"%K at index {} is out of range: {}",
|
||||
i,
|
||||
k
|
||||
);
|
||||
assert!(k >= 0.0 && k <= 100.0, "%K at index {} is out of range: {}", i, k);
|
||||
}
|
||||
}
|
||||
|
||||
for (i, &d) in result.d.iter().enumerate() {
|
||||
if !d.is_nan() {
|
||||
assert!(
|
||||
d >= 0.0 && d <= 100.0,
|
||||
"%D at index {} is out of range: {}",
|
||||
i,
|
||||
d
|
||||
);
|
||||
assert!(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 {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open: close
|
||||
.iter()
|
||||
.skip(1)
|
||||
.chain(std::iter::once(&close[n - 1]))
|
||||
.cloned()
|
||||
.collect(),
|
||||
open: close.iter().skip(1).chain(std::iter::once(&close[n - 1])).cloned().collect(),
|
||||
high: close.iter().map(|c| c + 1.0).collect(),
|
||||
low: close.iter().map(|c| c - 1.0).collect(),
|
||||
close: close.clone(),
|
||||
|
||||
Reference in New Issue
Block a user