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>
360 lines
11 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|