Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar (DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate definitions across features/, regime/, real_data_loader, and evaluation/ with imports from crate::types::OHLCVBar. Key changes: - New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default) - Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely different type: f32 fields, i64 timestamp for compact backtesting) - Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar, PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs - Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased) - Updated 39 files total (13 definitions removed, imports normalized) 1883 lib tests passing, compilation clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
5.0 KiB
Rust
173 lines
5.0 KiB
Rust
// Kelly Criterion Position Sizing Integration Test
|
|
//
|
|
// Validates that Kelly fraction is correctly applied to position sizing
|
|
// in the backtesting pipeline (PortfolioTracker → EvaluationEngine → Hyperopt).
|
|
|
|
use ml::evaluation::engine::{Action, EvaluationEngine};
|
|
use ml::evaluation::metrics::OHLCVBarF32;
|
|
|
|
#[test]
|
|
fn test_kelly_scales_pnl_correctly() {
|
|
// Test 1: Full Kelly (1.0) - baseline
|
|
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
|
|
|
|
// Open long position at $100
|
|
let bar1 = OHLCVBarF32 {
|
|
timestamp: 0,
|
|
open: 100.0,
|
|
high: 100.0,
|
|
low: 100.0,
|
|
close: 100.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(0, &bar1, Action::Buy);
|
|
|
|
// Close at $110 (10% gain)
|
|
let bar2 = OHLCVBarF32 {
|
|
timestamp: 1,
|
|
open: 110.0,
|
|
high: 110.0,
|
|
low: 110.0,
|
|
close: 110.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Full Kelly: PnL = 110 - 100 = $10
|
|
assert_eq!(engine_full.trades.len(), 1);
|
|
assert!((engine_full.trades[0].pnl - 10.0).abs() < 0.01);
|
|
|
|
// Test 2: Half Kelly (0.5) - reduced position
|
|
let mut engine_half = EvaluationEngine::new_with_kelly(10_000.0, 0.5);
|
|
engine_half.process_bar(0, &bar1, Action::Buy);
|
|
engine_half.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Half Kelly: PnL = (110 - 100) * 0.5 = $5
|
|
assert_eq!(engine_half.trades.len(), 1);
|
|
assert!((engine_half.trades[0].pnl - 5.0).abs() < 0.01);
|
|
|
|
// Test 3: Quarter Kelly (0.25) - conservative
|
|
let mut engine_quarter = EvaluationEngine::new_with_kelly(10_000.0, 0.25);
|
|
engine_quarter.process_bar(0, &bar1, Action::Buy);
|
|
engine_quarter.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Quarter Kelly: PnL = (110 - 100) * 0.25 = $2.50
|
|
assert_eq!(engine_quarter.trades.len(), 1);
|
|
assert!((engine_quarter.trades[0].pnl - 2.5).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kelly_scales_losses_correctly() {
|
|
// Test Kelly scaling on losing trades
|
|
|
|
// Full Kelly (1.0)
|
|
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
|
|
|
|
let bar1 = OHLCVBarF32 {
|
|
timestamp: 0,
|
|
open: 100.0,
|
|
high: 100.0,
|
|
low: 100.0,
|
|
close: 100.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(0, &bar1, Action::Buy);
|
|
|
|
// Price drops to $90 (-10% loss)
|
|
let bar2 = OHLCVBarF32 {
|
|
timestamp: 1,
|
|
open: 90.0,
|
|
high: 90.0,
|
|
low: 90.0,
|
|
close: 90.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Full Kelly: Loss = 90 - 100 = -$10
|
|
assert_eq!(engine_full.trades.len(), 1);
|
|
assert!((engine_full.trades[0].pnl - (-10.0)).abs() < 0.01);
|
|
|
|
// Half Kelly (0.5) - reduced loss
|
|
let mut engine_half = EvaluationEngine::new_with_kelly(10_000.0, 0.5);
|
|
engine_half.process_bar(0, &bar1, Action::Buy);
|
|
engine_half.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Half Kelly: Loss = (90 - 100) * 0.5 = -$5
|
|
assert_eq!(engine_half.trades.len(), 1);
|
|
assert!((engine_half.trades[0].pnl - (-5.0)).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_kelly_with_short_positions() {
|
|
// Test Kelly scaling on short positions
|
|
|
|
// Full Kelly (1.0)
|
|
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
|
|
|
|
// Short at $100
|
|
let bar1 = OHLCVBarF32 {
|
|
timestamp: 0,
|
|
open: 100.0,
|
|
high: 100.0,
|
|
low: 100.0,
|
|
close: 100.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(0, &bar1, Action::Sell);
|
|
|
|
// Cover at $90 (10% profit for short)
|
|
let bar2 = OHLCVBarF32 {
|
|
timestamp: 1,
|
|
open: 90.0,
|
|
high: 90.0,
|
|
low: 90.0,
|
|
close: 90.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_full.process_bar(1, &bar2, Action::Buy);
|
|
|
|
// Full Kelly: PnL = 100 - 90 = $10 (profit from price drop)
|
|
assert_eq!(engine_full.trades.len(), 1);
|
|
assert!((engine_full.trades[0].pnl - 10.0).abs() < 0.01);
|
|
|
|
// Half Kelly (0.5)
|
|
let mut engine_half = EvaluationEngine::new_with_kelly(10_000.0, 0.5);
|
|
engine_half.process_bar(0, &bar1, Action::Sell);
|
|
engine_half.process_bar(1, &bar2, Action::Buy);
|
|
|
|
// Half Kelly: PnL = (100 - 90) * 0.5 = $5
|
|
assert_eq!(engine_half.trades.len(), 1);
|
|
assert!((engine_half.trades[0].pnl - 5.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_new_uses_full_kelly() {
|
|
// Verify that EvaluationEngine::new() defaults to Kelly=1.0
|
|
let mut engine_default = EvaluationEngine::new(10_000.0);
|
|
|
|
let bar1 = OHLCVBarF32 {
|
|
timestamp: 0,
|
|
open: 100.0,
|
|
high: 100.0,
|
|
low: 100.0,
|
|
close: 100.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_default.process_bar(0, &bar1, Action::Buy);
|
|
|
|
let bar2 = OHLCVBarF32 {
|
|
timestamp: 1,
|
|
open: 110.0,
|
|
high: 110.0,
|
|
low: 110.0,
|
|
close: 110.0,
|
|
volume: 0.0,
|
|
};
|
|
engine_default.process_bar(1, &bar2, Action::Sell);
|
|
|
|
// Should behave like full Kelly (no scaling)
|
|
assert_eq!(engine_default.trades.len(), 1);
|
|
assert!((engine_default.trades[0].pnl - 10.0).abs() < 0.01);
|
|
}
|