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:
vatsal
2026-01-28 15:38:31 +05:30
committed by GitHub
parent e6476b771f
commit 0f032dd39e
32 changed files with 193 additions and 660 deletions
+7 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
}
+1 -4
View File
@@ -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
View File
@@ -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]
+3 -13
View File
@@ -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.
+4 -16
View File
@@ -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];
+2 -10
View File
@@ -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
View File
@@ -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)]
+7 -28
View File
@@ -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();
+1 -5
View File
@@ -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
}
+1 -5
View File
@@ -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()
}
+8 -19
View File
@@ -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)]
+2 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+4 -8
View File
@@ -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;
}
+6 -26
View File
@@ -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)
}
+6 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+8 -28
View File
@@ -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
View File
@@ -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];
+3 -9
View File
@@ -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(
&timestamps,
+2 -12
View File
@@ -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);
}
}
}
+1 -6
View File
@@ -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(),