Files
foxhunt/ml/tests/kelly_position_sizing_integration.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
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>
2026-02-20 18:14:42 +01:00

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);
}