扩展指标
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
//! Integration tests for RaptorBT indicators.
|
||||
|
||||
use raptorbt::indicators::ferro_bridge::{
|
||||
chandelier_exit, choppiness_index, detect_breaks_cusum, donchian, drawdown_series,
|
||||
ht_dcperiod, ht_dcphase, ht_trendline, hull_ma, ichimoku, pivot_points, regime_adx,
|
||||
relative_strength, rolling_beta, rolling_variance_break, ratio, spread, vwma, zscore_series,
|
||||
};
|
||||
use raptorbt::indicators::momentum::{macd, rsi, stochastic};
|
||||
use raptorbt::indicators::strength::adx;
|
||||
use raptorbt::indicators::trend::{ema, sma, supertrend};
|
||||
use raptorbt::indicators::volatility::{atr, bollinger_bands};
|
||||
use raptorbt::indicators::volume::vwap;
|
||||
|
||||
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
// Create sample OHLCV data with 50 bars
|
||||
let n = 50;
|
||||
let mut close: Vec<f64> = vec![100.0];
|
||||
let mut high: Vec<f64> = vec![101.0];
|
||||
let mut low: Vec<f64> = vec![99.0];
|
||||
let mut open: Vec<f64> = vec![100.0];
|
||||
let volume: Vec<f64> = vec![1000.0; n];
|
||||
|
||||
// Generate trending data
|
||||
for i in 1..n {
|
||||
let prev_close = close[i - 1];
|
||||
let change = ((i as f64 * 0.2).sin() * 2.0) + 0.5; // Slight uptrend with oscillation
|
||||
let new_close = prev_close + change;
|
||||
close.push(new_close);
|
||||
open.push(prev_close);
|
||||
high.push(new_close.max(prev_close) + 0.5);
|
||||
low.push(new_close.min(prev_close) - 0.5);
|
||||
}
|
||||
|
||||
(open, high, low, close, volume)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sma_correctness() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let result = sma(&data, 3).unwrap();
|
||||
|
||||
// First 2 values should be NaN
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
|
||||
// SMA(3) for [1,2,3] = 2.0
|
||||
assert!((result[2] - 2.0).abs() < 1e-10);
|
||||
// SMA(3) for [2,3,4] = 3.0
|
||||
assert!((result[3] - 3.0).abs() < 1e-10);
|
||||
// SMA(3) for [8,9,10] = 9.0
|
||||
assert!((result[9] - 9.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema_correctness() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
let result = ema(&data, 3).unwrap();
|
||||
|
||||
// First 2 values should be NaN
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
|
||||
// EMA should be valid from index 2
|
||||
assert!(!result[2].is_nan());
|
||||
assert!(!result[9].is_nan());
|
||||
|
||||
// EMA should be between min and max
|
||||
assert!(result[9] >= 1.0 && result[9] <= 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rsi_range() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = rsi(&close, 14).unwrap();
|
||||
|
||||
// Check RSI is in valid range [0, 100]
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= 0.0 && value <= 100.0,
|
||||
"RSI at index {} is out of range: {}",
|
||||
i,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macd_structure() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = macd(&close, 12, 26, 9).unwrap();
|
||||
|
||||
assert_eq!(result.macd_line.len(), close.len());
|
||||
assert_eq!(result.signal_line.len(), close.len());
|
||||
assert_eq!(result.histogram.len(), close.len());
|
||||
|
||||
// MACD line should be valid from index 25 (slow_period - 1)
|
||||
assert!(result.macd_line[24].is_nan());
|
||||
assert!(!result.macd_line[25].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stochastic_range() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = stochastic(&high, &low, &close, 14, 3).unwrap();
|
||||
|
||||
// %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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_positive() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = atr(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// ATR should always be non-negative
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(value >= 0.0, "ATR at index {} is negative: {}", i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bollinger_bands_ordering() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let result = bollinger_bands(&close, 20, 2.0).unwrap();
|
||||
|
||||
// Upper > Middle > Lower
|
||||
for i in 19..close.len() {
|
||||
if !result.upper[i].is_nan() {
|
||||
assert!(
|
||||
result.upper[i] >= result.middle[i],
|
||||
"Upper band should be >= middle at index {}",
|
||||
i
|
||||
);
|
||||
assert!(
|
||||
result.middle[i] >= result.lower[i],
|
||||
"Middle band should be >= lower at index {}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adx_range() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = adx(&high, &low, &close, 14).unwrap();
|
||||
|
||||
// ADX should be in [0, 100]
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= 0.0 && value <= 100.0,
|
||||
"ADX at index {} is out of range: {}",
|
||||
i,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vwap_bounds() {
|
||||
let (_, high, low, close, volume) = sample_ohlcv();
|
||||
let result = vwap(&high, &low, &close, &volume).unwrap();
|
||||
|
||||
// VWAP should be between the overall min low and max high
|
||||
let min_low = low.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let max_high = high.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_nan() {
|
||||
assert!(
|
||||
value >= min_low && value <= max_high,
|
||||
"VWAP at index {} is out of bounds: {} (should be between {} and {})",
|
||||
i,
|
||||
value,
|
||||
min_low,
|
||||
max_high
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supertrend_direction() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let result = supertrend(&high, &low, &close, 10, 3.0).unwrap();
|
||||
|
||||
// Direction should be either 1 or -1
|
||||
for (i, &dir) in result.direction.iter().enumerate() {
|
||||
if dir != 0 {
|
||||
assert!(
|
||||
dir == 1 || dir == -1,
|
||||
"Supertrend direction at index {} is invalid: {}",
|
||||
i,
|
||||
dir
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_period() {
|
||||
let data = vec![1.0, 2.0, 3.0];
|
||||
|
||||
// Period of 0 should error
|
||||
assert!(sma(&data, 0).is_err());
|
||||
assert!(ema(&data, 0).is_err());
|
||||
assert!(rsi(&data, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_data() {
|
||||
let empty: Vec<f64> = vec![];
|
||||
|
||||
let result = sma(&empty, 10).unwrap();
|
||||
assert!(result.is_empty());
|
||||
|
||||
let result = ema(&empty, 10).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// P0 batch — Extended / Cycle / Regime / Portfolio
|
||||
// =========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_vwma_basic() {
|
||||
let (open, high, low, close, volume) = sample_ohlcv();
|
||||
let _ = open;
|
||||
let _ = high;
|
||||
let _ = low;
|
||||
let r = vwma(&close, &volume, 5).unwrap();
|
||||
assert_eq!(r.len(), close.len());
|
||||
assert!(r[0].is_nan());
|
||||
assert!(r[3].is_nan());
|
||||
assert!(!r[4].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vwma_invalid_period() {
|
||||
let (_, _, _, close, volume) = sample_ohlcv();
|
||||
assert!(vwma(&close, &volume, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_donchian_basic() {
|
||||
let (_, high, low, _, _) = sample_ohlcv();
|
||||
let r = donchian(&high, &low, 10).unwrap();
|
||||
assert_eq!(r.upper.len(), high.len());
|
||||
assert_eq!(r.middle.len(), high.len());
|
||||
assert_eq!(r.lower.len(), high.len());
|
||||
for i in 9..high.len() {
|
||||
assert!(r.upper[i] >= r.middle[i]);
|
||||
assert!(r.middle[i] >= r.lower[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_donchian_invalid_period() {
|
||||
let (_, high, low, _, _) = sample_ohlcv();
|
||||
assert!(donchian(&high, &low, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choppiness_range() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let r = choppiness_index(&high, &low, &close, 14).unwrap();
|
||||
for (i, &v) in r.iter().enumerate() {
|
||||
if !v.is_nan() {
|
||||
assert!(v >= 0.0 && v <= 100.0, "CI at {} out of range: {}", i, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hull_ma_basic() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let r = hull_ma(&close, 20).unwrap();
|
||||
assert_eq!(r.len(), close.len());
|
||||
let valid = r.iter().filter(|v| !v.is_nan()).count();
|
||||
assert!(valid > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chandelier_exit_ordering() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let r = chandelier_exit(&high, &low, &close, 10, 2.0).unwrap();
|
||||
assert_eq!(r.long_exit.len(), high.len());
|
||||
assert_eq!(r.short_exit.len(), high.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chandelier_invalid() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
assert!(chandelier_exit(&high, &low, &close, 0, 2.0).is_err());
|
||||
assert!(chandelier_exit(&high, &low, &close, 10, -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ichimoku_structure() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let r = ichimoku(&high, &low, &close, 9, 26, 52, 26).unwrap();
|
||||
assert_eq!(r.tenkan.len(), close.len());
|
||||
assert_eq!(r.kijun.len(), close.len());
|
||||
assert_eq!(r.senkou_a.len(), close.len());
|
||||
assert_eq!(r.senkou_b.len(), close.len());
|
||||
assert_eq!(r.chikou.len(), close.len());
|
||||
// tenkan period 9 -> first valid at index 8
|
||||
assert!(r.tenkan[7].is_nan());
|
||||
assert!(!r.tenkan[8].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pivot_points_classic() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
let r = pivot_points(&high, &low, &close, "classic").unwrap();
|
||||
assert_eq!(r.pivot.len(), close.len());
|
||||
assert!(r.pivot[0].is_nan());
|
||||
assert!(!r.pivot[1].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pivot_points_unknown_method() {
|
||||
let (_, high, low, close, _) = sample_ohlcv();
|
||||
assert!(pivot_points(&high, &low, &close, "bogus").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_trendline_min_length() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
assert!(ht_trendline(&close).is_ok());
|
||||
let short = vec![1.0; 10];
|
||||
assert!(ht_trendline(&short).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_dcperiod_dcphase_basic() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let p = ht_dcperiod(&close).unwrap();
|
||||
let ph = ht_dcphase(&close).unwrap();
|
||||
assert_eq!(p.len(), close.len());
|
||||
assert_eq!(ph.len(), close.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regime_adx_labels() {
|
||||
// Synthesize ADX series
|
||||
let adx_input: Vec<f64> = (0..50).map(|i| if i < 25 { 10.0 } else { 30.0 }).collect();
|
||||
let r = regime_adx(&adx_input, 20.0).unwrap();
|
||||
// First 25 bars -> range (0), last 25 -> trend (1)
|
||||
for i in 0..25 {
|
||||
assert_eq!(r[i], 0, "idx {} expected 0 got {}", i, r[i]);
|
||||
}
|
||||
for i in 25..50 {
|
||||
assert_eq!(r[i], 1, "idx {} expected 1 got {}", i, r[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_breaks_cusum_basic() {
|
||||
let series: Vec<f64> = (0..100)
|
||||
.map(|i| if i < 50 { 0.0 } else { 5.0 })
|
||||
.collect();
|
||||
let r = detect_breaks_cusum(&series, 10, 5.0, 0.5).unwrap();
|
||||
assert_eq!(r.len(), series.len());
|
||||
assert_eq!(r[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_breaks_cusum_invalid_window() {
|
||||
let v = vec![1.0; 10];
|
||||
assert!(detect_breaks_cusum(&v, 1, 1.0, 0.5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_variance_break_basic() {
|
||||
let series: Vec<f64> = (0..60)
|
||||
.map(|i| if i < 40 { 0.01 } else { 1.0 })
|
||||
.collect();
|
||||
let r = rolling_variance_break(&series, 5, 20, 2.0).unwrap();
|
||||
assert_eq!(r.len(), series.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_variance_break_invalid() {
|
||||
let v = vec![1.0; 30];
|
||||
assert!(rolling_variance_break(&v, 1, 10, 1.0).is_err());
|
||||
assert!(rolling_variance_break(&v, 5, 5, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_beta_basic() {
|
||||
let (_, _, _, close, _) = sample_ohlcv();
|
||||
let bench: Vec<f64> = close.iter().map(|x| x * 0.5 + 1.0).collect();
|
||||
let r = rolling_beta(&close, &bench, 14).unwrap();
|
||||
assert_eq!(r.len(), close.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_beta_invalid_window() {
|
||||
let v = vec![1.0; 10];
|
||||
assert!(rolling_beta(&v, &v, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_series_basic() {
|
||||
let equity = vec![100.0, 110.0, 105.0, 120.0, 90.0, 95.0, 130.0];
|
||||
let r = drawdown_series(&equity).unwrap();
|
||||
assert_eq!(r.series.len(), equity.len());
|
||||
assert!(r.max_drawdown <= 0.0);
|
||||
// Max drawdown: 90 / 120 - 1 = -0.25
|
||||
assert!((r.max_drawdown - (-0.25)).abs() < 1e-9);
|
||||
// Per-bar dd is non-positive at all valid points
|
||||
for &v in &r.series {
|
||||
assert!(v <= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zscore_series_basic() {
|
||||
let x: Vec<f64> = (0..30).map(|i| i as f64).collect();
|
||||
let r = zscore_series(&x, 10).unwrap();
|
||||
assert_eq!(r.len(), x.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zscore_invalid_window() {
|
||||
let v = vec![1.0; 10];
|
||||
assert!(zscore_series(&v, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_strength_basic() {
|
||||
let a: Vec<f64> = (0..50).map(|i| (i as f64) * 0.01).collect();
|
||||
let b: Vec<f64> = (0..50).map(|i| (i as f64) * 0.005).collect();
|
||||
let r = relative_strength(&a, &b).unwrap();
|
||||
assert_eq!(r.len(), a.len());
|
||||
// RS should be positive: a - beta*b > 0 since a > b
|
||||
for i in 10..r.len() {
|
||||
assert!(r[i] > 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_strength_length_mismatch() {
|
||||
let a = vec![1.0; 10];
|
||||
let b = vec![2.0; 5];
|
||||
assert!(relative_strength(&a, &b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spread_and_ratio() {
|
||||
let a = vec![10.0, 20.0, 30.0];
|
||||
let b = vec![1.0, 2.0, 3.0];
|
||||
let s = spread(&a, &b, 2.0).unwrap();
|
||||
let r = ratio(&a, &b).unwrap();
|
||||
assert_eq!(s.len(), a.len());
|
||||
assert_eq!(r.len(), a.len());
|
||||
assert!((s[0] - 8.0).abs() < 1e-9); // 10 - 2*1
|
||||
assert!((r[0] - 10.0).abs() < 1e-9); // 10/1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spread_length_mismatch() {
|
||||
let a = vec![1.0; 5];
|
||||
let b = vec![2.0; 3];
|
||||
assert!(spread(&a, &b, 1.0).is_err());
|
||||
assert!(ratio(&a, &b).is_err());
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Integration tests for RaptorBT portfolio engine.
|
||||
|
||||
use raptorbt::core::types::{
|
||||
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
|
||||
};
|
||||
use raptorbt::portfolio::engine::PortfolioEngine;
|
||||
|
||||
fn sample_ohlcv() -> OhlcvData {
|
||||
// Create trending sample data
|
||||
let n = 100;
|
||||
let mut close = vec![100.0];
|
||||
let mut open = vec![100.0];
|
||||
let mut high = vec![101.0];
|
||||
let mut low = vec![99.0];
|
||||
|
||||
for i in 1..n {
|
||||
let trend = (i as f64) * 0.5; // Upward trend
|
||||
let noise = ((i as f64) * 0.3).sin() * 2.0;
|
||||
let new_close = 100.0 + trend + noise;
|
||||
close.push(new_close);
|
||||
open.push(close[i - 1]);
|
||||
high.push(new_close + 1.0);
|
||||
low.push(new_close - 1.0);
|
||||
}
|
||||
|
||||
OhlcvData {
|
||||
timestamps: (0..n as i64).collect(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume: vec![1000.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_signals(n: usize) -> CompiledSignals {
|
||||
// Entry at bar 10, exit at bar 50
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[50] = true;
|
||||
|
||||
CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_backtest() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 1 complete trade
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
|
||||
// Equity curve should have same length as data
|
||||
assert_eq!(result.equity_curve.len(), ohlcv.len());
|
||||
|
||||
// In an uptrend, should have positive return
|
||||
assert!(result.metrics.total_return_pct > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_trades() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Multiple trades
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[20] = true;
|
||||
entries[30] = true;
|
||||
exits[40] = true;
|
||||
entries[50] = true;
|
||||
exits[60] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have 3 trades
|
||||
assert_eq!(result.trades.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_fees() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig {
|
||||
fees: 0.01, // 1% fee
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Trade should have fees deducted
|
||||
assert!(result.trades[0].fees > 0.0);
|
||||
|
||||
// Return should be lower due to fees
|
||||
let config_no_fees = BacktestConfig::default();
|
||||
let engine_no_fees = PortfolioEngine::new(config_no_fees);
|
||||
let result_no_fees = engine_no_fees.run_single(&ohlcv, &signals);
|
||||
|
||||
assert!(result.metrics.end_value < result_no_fees.metrics.end_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_stop_loss() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Entry at bar 10
|
||||
let mut entries = vec![false; n];
|
||||
entries[10] = true;
|
||||
let exits = vec![false; n]; // No exit signal
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig {
|
||||
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have at least one trade (may exit on stop or end of data)
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_take_profit() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Entry at bar 10
|
||||
let mut entries = vec![false; n];
|
||||
entries[10] = true;
|
||||
let exits = vec![false; n]; // No exit signal
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig {
|
||||
target: TargetConfig::Fixed { percent: 0.10 }, // 10% target
|
||||
..Default::default()
|
||||
};
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have at least one trade
|
||||
assert!(!result.trades.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trades() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// No entry signals
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries: vec![false; n],
|
||||
exits: vec![false; n],
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Should have no trades
|
||||
assert_eq!(result.trades.len(), 0);
|
||||
assert_eq!(result.metrics.total_trades, 0);
|
||||
|
||||
// Equity should remain at initial capital
|
||||
assert!((result.metrics.end_value - result.metrics.start_value).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_positive() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let signals = simple_signals(ohlcv.len());
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// All drawdown values should be non-negative
|
||||
for dd in &result.drawdown_curve {
|
||||
assert!(*dd >= 0.0, "Drawdown should be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_direction() {
|
||||
// Create downtrend data
|
||||
let n = 100;
|
||||
let mut close = vec![100.0];
|
||||
for i in 1..n {
|
||||
close.push(100.0 - (i as f64) * 0.3); // Downward trend
|
||||
}
|
||||
|
||||
let ohlcv = OhlcvData {
|
||||
timestamps: (0..n as i64).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(),
|
||||
volume: vec![1000.0; n],
|
||||
};
|
||||
|
||||
// Entry at bar 10, exit at bar 50
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
entries[10] = true;
|
||||
exits[50] = true;
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Short, // Short direction
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Short in a downtrend should be profitable
|
||||
assert!(result.trades[0].pnl > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_consistency() {
|
||||
let ohlcv = sample_ohlcv();
|
||||
let n = ohlcv.len();
|
||||
|
||||
// Multiple trades for statistics
|
||||
let mut entries = vec![false; n];
|
||||
let mut exits = vec![false; n];
|
||||
for i in (10..90).step_by(20) {
|
||||
entries[i] = true;
|
||||
exits[i + 10] = true;
|
||||
}
|
||||
|
||||
let signals = CompiledSignals {
|
||||
symbol: "TEST".to_string(),
|
||||
entries,
|
||||
exits,
|
||||
position_sizes: None,
|
||||
direction: Direction::Long,
|
||||
weight: 1.0,
|
||||
};
|
||||
|
||||
let config = BacktestConfig::default();
|
||||
let engine = PortfolioEngine::new(config);
|
||||
let result = engine.run_single(&ohlcv, &signals);
|
||||
|
||||
// Total trades should equal winning + losing
|
||||
assert_eq!(
|
||||
result.metrics.total_trades,
|
||||
result.metrics.winning_trades + result.metrics.losing_trades
|
||||
);
|
||||
|
||||
// Win rate should be in [0, 100]
|
||||
assert!(result.metrics.win_rate_pct >= 0.0);
|
||||
assert!(result.metrics.win_rate_pct <= 100.0);
|
||||
|
||||
// Exposure should be in [0, 100]
|
||||
assert!(result.metrics.exposure_pct >= 0.0);
|
||||
assert!(result.metrics.exposure_pct <= 100.0);
|
||||
}
|
||||
Reference in New Issue
Block a user