310 lines
8.1 KiB
Rust
310 lines
8.1 KiB
Rust
//! 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);
|
|
}
|