Files
foxhunt/tests/integration/ml_order_pipeline_test.rs
jgrusewski 2bce9859cc test(trading_service): unit tests for risk service VaR and risk limits
Add 25 unit tests for the RiskServiceImpl pure functions:
- Parametric VaR fallback formula (notional * 0.02)
- Equal contribution percentage for N symbols (including empty)
- Drawdown computation (empty, positive PnL, negative, mixed)
- Returns from executions (empty, single, sorted, zero-price filtering)
- Volatility (empty, single, constant, known series)
- Sharpe ratio (insufficient data, zero vol, positive returns)
- Sortino ratio (insufficient data, no downside, mixed)
- VaR square-root-of-time scaling (1d→5d→30d)
- Concentration risk level thresholds
- Risk constants validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:49:50 +01:00

360 lines
11 KiB
Rust

//! Integration test: ML inference -> ensemble vote -> order generation
//!
//! Verifies the complete pipeline from feature vector through model
//! inference, ensemble aggregation, and order signal generation.
//! This is a critical path test for the ML -> Order execution pipeline.
use ml::ensemble::inference_adapter::{
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
};
use ml::ensemble::inference_ensemble::InferenceEnsemble;
use ml::MLResult;
/// Test adapter that simulates a bullish DQN model
struct MockDQNAdapter;
impl ModelInferenceAdapter for MockDQNAdapter {
fn model_name(&self) -> &str {
"DQN-v1"
}
fn predict(&self, _features: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "DQN-v1".to_string(),
direction: 0.8,
confidence: 0.85,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool {
true
}
}
/// Test adapter that simulates a bearish PPO model
struct MockPPOAdapter;
impl ModelInferenceAdapter for MockPPOAdapter {
fn model_name(&self) -> &str {
"PPO-v1"
}
fn predict(&self, _features: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "PPO-v1".to_string(),
direction: -0.3,
confidence: 0.6,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool {
true
}
}
/// Test adapter that returns an error (simulates model failure)
struct FailingAdapter;
impl ModelInferenceAdapter for FailingAdapter {
fn model_name(&self) -> &str {
"FailingModel"
}
fn predict(&self, _features: &FeatureVector) -> MLResult<EnsemblePrediction> {
Err(ml::MLError::InferenceError(
"Simulated model failure".to_string(),
))
}
fn is_ready(&self) -> bool {
true
}
}
/// Test adapter that returns NaN direction and confidence
struct NaNAdapter;
impl ModelInferenceAdapter for NaNAdapter {
fn model_name(&self) -> &str {
"NaN-model"
}
fn predict(&self, _features: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "NaN-model".to_string(),
direction: f64::NAN,
confidence: f64::NAN,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool {
true
}
}
/// Test adapter that is never ready (simulates an unloaded model)
struct NotReadyAdapter;
impl ModelInferenceAdapter for NotReadyAdapter {
fn model_name(&self) -> &str {
"NotReady"
}
fn predict(&self, _features: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "NotReady".to_string(),
direction: 1.0,
confidence: 1.0,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool {
false
}
}
fn make_feature_vector() -> FeatureVector {
FeatureVector {
values: vec![0.1; 51],
timestamp: 1_700_000_000_000_000,
}
}
// ---------------------------------------------------------------------------
// Test 1: Full ML -> Order pipeline
// ---------------------------------------------------------------------------
#[test]
fn test_ml_to_order_pipeline() {
// 1. Create a canonical 51-dim feature vector
let features = make_feature_vector();
assert_eq!(features.values.len(), 51, "Feature vector must be 51-dim");
// 2. Create ensemble with mock adapters (bullish DQN + bearish PPO)
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(MockDQNAdapter),
Box::new(MockPPOAdapter),
];
let ensemble = InferenceEnsemble::new(adapters);
// 3. Verify both models are ready
assert_eq!(ensemble.ready_count(), 2, "Both mock models should be ready");
// 4. Run ensemble prediction
let prediction = ensemble.predict(&features);
assert!(prediction.is_ok(), "Ensemble prediction should succeed");
let pred = prediction.unwrap_or_else(|e| panic!("Prediction failed: {e}"));
// 5. Verify prediction properties are well-formed
assert!(pred.direction.is_finite(), "Direction must be finite");
assert!(pred.confidence.is_finite(), "Confidence must be finite");
assert!(
pred.confidence >= 0.0 && pred.confidence <= 1.0,
"Confidence {} should be in [0.0, 1.0]",
pred.confidence
);
assert!(
pred.direction >= -1.0 && pred.direction <= 1.0,
"Direction {} should be in [-1.0, 1.0]",
pred.direction
);
// 6. Generate order signal from prediction
// The DQN model (dir=0.8, conf=0.85) dominates the PPO model (dir=-0.3, conf=0.6)
// because higher confidence gives it more weight in the ensemble.
// Expected net direction: positive (bullish).
let order_side = if pred.direction > 0.0 { "Buy" } else { "Sell" };
let order_size = (pred.confidence * 100.0).round();
assert_eq!(
order_side, "Buy",
"Net bullish ensemble (DQN dominates) should generate Buy, got direction={}",
pred.direction
);
assert!(
order_size > 0.0,
"Order size should be positive, got {}",
order_size
);
assert!(
order_size <= 100.0,
"Order size should be <= 100, got {}",
order_size
);
// 7. Verify model name reflects aggregation
assert!(
pred.model_name.contains("ENSEMBLE"),
"Aggregated prediction model_name should contain 'ENSEMBLE', got '{}'",
pred.model_name
);
}
// ---------------------------------------------------------------------------
// Test 2: Ensemble handles all-NaN models gracefully
// ---------------------------------------------------------------------------
#[test]
fn test_ensemble_handles_all_models_returning_nan() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![Box::new(NaNAdapter)];
let ensemble = InferenceEnsemble::new(adapters);
let features = make_feature_vector();
// The NaN circuit breaker should filter out the NaN model,
// leaving zero successful predictions -> error.
let result = ensemble.predict(&features);
assert!(
result.is_err(),
"All-NaN ensemble should return error, but got: {:?}",
result
);
}
// ---------------------------------------------------------------------------
// Test 3: Ensemble handles a mix of good + failing models
// ---------------------------------------------------------------------------
#[test]
fn test_ensemble_survives_partial_model_failure() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(MockDQNAdapter),
Box::new(FailingAdapter),
];
let ensemble = InferenceEnsemble::new(adapters);
let features = make_feature_vector();
// The failing model is skipped; DQN alone should produce a valid prediction.
let result = ensemble.predict(&features);
assert!(
result.is_ok(),
"Ensemble with one good model should succeed, got: {:?}",
result
);
let pred = result.unwrap_or_else(|e| panic!("Prediction failed: {e}"));
assert!(
pred.direction.is_finite(),
"Direction must be finite after partial failure"
);
assert!(
pred.confidence.is_finite(),
"Confidence must be finite after partial failure"
);
}
// ---------------------------------------------------------------------------
// Test 4: Ensemble with no ready models
// ---------------------------------------------------------------------------
#[test]
fn test_ensemble_no_ready_models_returns_error() {
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![Box::new(NotReadyAdapter)];
let ensemble = InferenceEnsemble::new(adapters);
let features = make_feature_vector();
let result = ensemble.predict(&features);
assert!(
result.is_err(),
"Ensemble with no ready models should return error"
);
}
// ---------------------------------------------------------------------------
// Test 5: Ensemble with custom weights changes outcome
// ---------------------------------------------------------------------------
#[test]
fn test_ensemble_custom_weights_affect_direction() {
// Two opposing models with equal confidence
struct BullAdapter;
impl ModelInferenceAdapter for BullAdapter {
fn model_name(&self) -> &str { "Bull" }
fn predict(&self, _: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "Bull".to_string(),
direction: 1.0,
confidence: 0.8,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool { true }
}
struct BearAdapter;
impl ModelInferenceAdapter for BearAdapter {
fn model_name(&self) -> &str { "Bear" }
fn predict(&self, _: &FeatureVector) -> MLResult<EnsemblePrediction> {
Ok(EnsemblePrediction {
model_name: "Bear".to_string(),
direction: -1.0,
confidence: 0.8,
metadata: PredictionMeta::default(),
})
}
fn is_ready(&self) -> bool { true }
}
let adapters: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(BullAdapter),
Box::new(BearAdapter),
];
// Without custom weights, equal confidence => direction ~ 0.0
let ensemble_equal = InferenceEnsemble::new(adapters);
let features = make_feature_vector();
let pred_equal = ensemble_equal
.predict(&features)
.unwrap_or_else(|e| panic!("Equal-weight prediction failed: {e}"));
assert!(
pred_equal.direction.abs() < 0.01,
"Equal weight/confidence opposing models should cancel out, got {}",
pred_equal.direction
);
// With Bull weighted 3x heavier, direction should be strongly positive
let adapters2: Vec<Box<dyn ModelInferenceAdapter>> = vec![
Box::new(BullAdapter),
Box::new(BearAdapter),
];
let mut ensemble_weighted = InferenceEnsemble::new(adapters2);
ensemble_weighted.set_weight("Bull", 3.0);
ensemble_weighted.set_weight("Bear", 1.0);
let pred_weighted = ensemble_weighted
.predict(&features)
.unwrap_or_else(|e| panic!("Weighted prediction failed: {e}"));
assert!(
pred_weighted.direction > 0.3,
"Bull-weighted ensemble should have positive direction, got {}",
pred_weighted.direction
);
}
// ---------------------------------------------------------------------------
// Test 6: Order sizing from confidence
// ---------------------------------------------------------------------------
#[test]
fn test_order_sizing_from_confidence() {
// Verify that different confidence levels produce proportional order sizes
for (conf, expected_min, expected_max) in [
(0.0, 0.0, 0.0),
(0.5, 49.0, 51.0),
(1.0, 99.0, 101.0),
] {
let size = (conf * 100.0_f64).round();
assert!(
size >= expected_min && size <= expected_max,
"Confidence {} -> size {}, expected [{}, {}]",
conf,
size,
expected_min,
expected_max
);
}
}