Files
foxhunt/ml/tests/kelly_position_sizing_integration.rs
jgrusewski 6f6a6a9972 fix(ml): fix PPO flow policy shape dimensions and clean up tests
- Remove unnecessary unsqueeze(1) in FlowPolicy log_prob and entropy
  (shapes should be [batch_size], not [batch_size, 1])
- Update flow_policy tests to expect correct [batch_size] shape
- Simplify kelly position sizing test with helper function
- Clean up example files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:52:12 +01:00

124 lines
6.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;
/// Compute expected net PnL after transaction costs (0.15% per side).
/// This mirrors EvaluationEngine::close_position().
fn expected_net_pnl(entry: f32, exit: f32, kelly: f64, is_long: bool) -> f32 {
let base_pnl = if is_long { exit - entry } else { entry - exit };
let gross = base_pnl * kelly as f32;
let fee: f32 = 0.0015;
let entry_cost = entry * kelly as f32 * fee;
let exit_cost = exit * kelly as f32 * fee;
gross - entry_cost - exit_cost
}
#[test]
fn test_kelly_scales_pnl_correctly() {
let bar1 = OHLCVBarF32 { timestamp: 0, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 0.0 };
let bar2 = OHLCVBarF32 { timestamp: 1, open: 110.0, high: 110.0, low: 110.0, close: 110.0, volume: 0.0 };
// Test 1: Full Kelly (1.0) - baseline
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
engine_full.process_bar(0, &bar1, Action::Buy);
engine_full.process_bar(1, &bar2, Action::Sell);
assert_eq!(engine_full.trades.len(), 1);
let expected = expected_net_pnl(100.0, 110.0, 1.0, true);
assert!((engine_full.trades[0].pnl - expected).abs() < 0.01,
"Full Kelly: expected {:.4}, got {:.4}", expected, engine_full.trades[0].pnl);
// 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);
assert_eq!(engine_half.trades.len(), 1);
let expected = expected_net_pnl(100.0, 110.0, 0.5, true);
assert!((engine_half.trades[0].pnl - expected).abs() < 0.01,
"Half Kelly: expected {:.4}, got {:.4}", expected, engine_half.trades[0].pnl);
// 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);
assert_eq!(engine_quarter.trades.len(), 1);
let expected = expected_net_pnl(100.0, 110.0, 0.25, true);
assert!((engine_quarter.trades[0].pnl - expected).abs() < 0.01,
"Quarter Kelly: expected {:.4}, got {:.4}", expected, engine_quarter.trades[0].pnl);
// Verify proportional scaling: half Kelly PnL ~= full Kelly PnL * 0.5
let ratio = engine_half.trades[0].pnl / engine_full.trades[0].pnl;
assert!((ratio - 0.5).abs() < 0.01, "Half/Full ratio should be ~0.5, got {:.4}", ratio);
}
#[test]
fn test_kelly_scales_losses_correctly() {
let bar1 = OHLCVBarF32 { timestamp: 0, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 0.0 };
let bar2 = OHLCVBarF32 { timestamp: 1, open: 90.0, high: 90.0, low: 90.0, close: 90.0, volume: 0.0 };
// Full Kelly (1.0) - loss
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
engine_full.process_bar(0, &bar1, Action::Buy);
engine_full.process_bar(1, &bar2, Action::Sell);
assert_eq!(engine_full.trades.len(), 1);
let expected = expected_net_pnl(100.0, 90.0, 1.0, true);
assert!((engine_full.trades[0].pnl - expected).abs() < 0.01,
"Full Kelly loss: expected {:.4}, got {:.4}", expected, engine_full.trades[0].pnl);
// 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);
assert_eq!(engine_half.trades.len(), 1);
let expected = expected_net_pnl(100.0, 90.0, 0.5, true);
assert!((engine_half.trades[0].pnl - expected).abs() < 0.01,
"Half Kelly loss: expected {:.4}, got {:.4}", expected, engine_half.trades[0].pnl);
}
#[test]
fn test_kelly_with_short_positions() {
let bar1 = OHLCVBarF32 { timestamp: 0, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 0.0 };
let bar2 = OHLCVBarF32 { timestamp: 1, open: 90.0, high: 90.0, low: 90.0, close: 90.0, volume: 0.0 };
// Full Kelly (1.0) - short profit
let mut engine_full = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
engine_full.process_bar(0, &bar1, Action::Sell);
engine_full.process_bar(1, &bar2, Action::Buy);
assert_eq!(engine_full.trades.len(), 1);
let expected = expected_net_pnl(100.0, 90.0, 1.0, false);
assert!((engine_full.trades[0].pnl - expected).abs() < 0.01,
"Full Kelly short: expected {:.4}, got {:.4}", expected, engine_full.trades[0].pnl);
// 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);
assert_eq!(engine_half.trades.len(), 1);
let expected = expected_net_pnl(100.0, 90.0, 0.5, false);
assert!((engine_half.trades[0].pnl - expected).abs() < 0.01,
"Half Kelly short: expected {:.4}, got {:.4}", expected, engine_half.trades[0].pnl);
}
#[test]
fn test_default_new_uses_full_kelly() {
let bar1 = OHLCVBarF32 { timestamp: 0, open: 100.0, high: 100.0, low: 100.0, close: 100.0, volume: 0.0 };
let bar2 = OHLCVBarF32 { timestamp: 1, open: 110.0, high: 110.0, low: 110.0, close: 110.0, volume: 0.0 };
// Verify that EvaluationEngine::new() defaults to Kelly=1.0
let mut engine_default = EvaluationEngine::new(10_000.0);
engine_default.process_bar(0, &bar1, Action::Buy);
engine_default.process_bar(1, &bar2, Action::Sell);
assert_eq!(engine_default.trades.len(), 1);
// Compare with explicit Kelly=1.0
let mut engine_explicit = EvaluationEngine::new_with_kelly(10_000.0, 1.0);
engine_explicit.process_bar(0, &bar1, Action::Buy);
engine_explicit.process_bar(1, &bar2, Action::Sell);
assert!((engine_default.trades[0].pnl - engine_explicit.trades[0].pnl).abs() < 0.001,
"Default should match explicit Kelly=1.0");
}