Files
foxhunt/testing/integration/integration/ensemble_integration.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

179 lines
5.4 KiB
Rust

//! Ensemble integration test -- all 4 models contributing to a single trading decision
//!
//! Verifies: DQN + PPO + TFT + Mamba2 -> EnsembleCoordinator -> EnsembleDecision
//! This is the critical test that proves the ensemble pipeline works end-to-end.
use ml::dqn::dqn::DQNConfig;
use ml::ensemble::adapters::{
DqnInferenceAdapter, Mamba2InferenceAdapter, PpoInferenceAdapter, TftInferenceAdapter,
};
use ml::ensemble::coordinator::EnsembleCoordinator;
use ml::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter};
use ml::ensemble::TradingAction;
use ml::mamba::Mamba2Config;
use ml::ppo::ppo::PPOConfig;
use ml::tft::TFTConfig;
use ml::Features;
fn small_dqn_config() -> DQNConfig {
DQNConfig {
state_dim: 51,
num_actions: 45,
hidden_dims: vec![32, 32],
..Default::default()
}
}
fn small_ppo_config() -> PPOConfig {
PPOConfig {
state_dim: 64,
num_actions: 45,
policy_hidden_dims: vec![32, 32],
value_hidden_dims: vec![32, 32],
..Default::default()
}
}
fn small_tft_config() -> TFTConfig {
TFTConfig {
input_dim: 20,
hidden_dim: 32,
num_heads: 2,
num_layers: 1,
prediction_horizon: 5,
sequence_length: 4,
num_quantiles: 9,
num_static_features: 6,
num_known_features: 6,
num_unknown_features: 8,
dropout_rate: 0.0,
..Default::default()
}
}
fn small_mamba2_config() -> Mamba2Config {
Mamba2Config {
d_model: 32,
d_state: 8,
d_head: 8,
num_heads: 2,
expand: 2,
num_layers: 1,
max_seq_len: 8,
dropout: 0.0,
..Default::default()
}
}
const TFT_SEQ_LEN: usize = 4;
const MAMBA2_SEQ_LEN: usize = 4;
#[tokio::test]
async fn test_ensemble_all_4_models_produce_decision() {
// Create all 4 adapters
let dqn = DqnInferenceAdapter::new(small_dqn_config()).expect("DQN adapter creation failed");
let ppo = PpoInferenceAdapter::new(small_ppo_config()).expect("PPO adapter creation failed");
let tft =
TftInferenceAdapter::new(small_tft_config(), TFT_SEQ_LEN).expect("TFT adapter creation failed");
let mamba2 = Mamba2InferenceAdapter::new(small_mamba2_config(), MAMBA2_SEQ_LEN)
.expect("Mamba2 adapter creation failed");
// Warm up TFT and Mamba2 sequence buffers
for i in 0..TFT_SEQ_LEN {
let fv = FeatureVector {
values: vec![0.1; 51],
timestamp: 1_700_000_000 + i as i64,
};
let _ = tft.predict(&fv);
let _ = mamba2.predict(&fv);
}
// Verify all adapters are ready
assert!(dqn.is_ready(), "DQN should be ready");
assert!(ppo.is_ready(), "PPO should be ready");
assert!(tft.is_ready(), "TFT should be ready after warmup");
assert!(mamba2.is_ready(), "Mamba2 should be ready after warmup");
// Build ensemble coordinator
let mut coordinator = EnsembleCoordinator::new();
coordinator.add_adapter(Box::new(dqn));
coordinator.add_adapter(Box::new(ppo));
coordinator.add_adapter(Box::new(tft));
coordinator.add_adapter(Box::new(mamba2));
// Register models with equal weights
coordinator
.register_model("DQN".to_string(), 0.25)
.await
.expect("register DQN");
coordinator
.register_model("PPO".to_string(), 0.25)
.await
.expect("register PPO");
coordinator
.register_model("TFT".to_string(), 0.25)
.await
.expect("register TFT");
coordinator
.register_model("MAMBA-2".to_string(), 0.25)
.await
.expect("register MAMBA-2");
// Create Features for ensemble prediction
let features = Features::new(
vec![0.1; 51],
(0..51).map(|i| format!("f{}", i)).collect(),
);
// Get ensemble decision
let decision = coordinator
.predict(&features)
.await
.expect("Ensemble prediction should succeed");
// Verify decision is valid
assert!(
decision.confidence >= 0.0 && decision.confidence <= 1.0,
"ensemble confidence {} out of [0,1]",
decision.confidence
);
assert!(
decision.signal >= -1.0 && decision.signal <= 1.0,
"ensemble signal {} out of [-1,1]",
decision.signal
);
assert!(decision.signal.is_finite(), "signal must not be NaN/Inf");
assert!(
decision.confidence.is_finite(),
"confidence must not be NaN/Inf"
);
// Verify we got votes from all 4 models
assert!(!decision.model_votes.is_empty(), "Should have model votes");
// Action should be Buy, Sell, or Hold
match decision.action {
TradingAction::Buy | TradingAction::Sell | TradingAction::Hold => {} // Valid
}
println!(
"Ensemble decision: action={:?}, signal={:.4}, confidence={:.4}, disagreement={:.4}",
decision.action, decision.signal, decision.confidence, decision.disagreement_rate
);
for (name, vote) in &decision.model_votes {
println!(
" {} -> signal={:.4}, confidence={:.4}",
name, vote.signal, vote.confidence
);
}
}
#[tokio::test]
async fn test_ensemble_no_adapters_returns_error() {
let coordinator = EnsembleCoordinator::new();
let features = Features::new(vec![0.1; 51], vec!["f0".to_string()]);
let result = coordinator.predict(&features).await;
assert!(result.is_err(), "Empty ensemble should return error");
}