test(trading_service): add E2E pipeline integration test (10 models)

Proves the critical trading pipeline path works end-to-end:
OHLCV -> features -> ensemble -> prediction. Uses all 10 real candle
inference adapters (DQN, PPO, TFT, Mamba2, Liquid-CfC, TGGN, TLOB,
KAN, xLSTM, Diffusion) with random weights, 60 synthetic OHLCV bars,
and asserts confidence/action/model-count invariants. No external
dependencies (no DB, no Docker).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 16:34:06 +01:00
parent c8bc3504f0
commit f81bd3fc2e

View File

@@ -0,0 +1,423 @@
//! End-to-end pipeline integration test: OHLCV -> Features -> Ensemble -> Prediction
//!
//! Proves the critical trading pipeline path works with all 10 real candle
//! inference adapters (DQN, PPO, TFT, Mamba2, Liquid-CfC, TGGN, TLOB, KAN,
//! xLSTM, Diffusion) using random (untrained) weights.
//!
//! No external dependencies: no DB, no Docker, no network.
use std::sync::Arc;
use chrono::Utc;
use trading_service::adapter_bridge::InferenceAdapterBridge;
use trading_service::ensemble_coordinator::EnsembleCoordinator;
/// Register all 10 inference adapters into the coordinator.
///
/// This mirrors the exact construction pattern from `main.rs` in the
/// trading_service binary so the test exercises the real production path.
async fn register_all_10_adapters(coordinator: &EnsembleCoordinator) -> usize {
let mut registered = 0usize;
// 1. DQN adapter (51-dim input, 45 factored actions)
match ml::ensemble::adapters::DqnInferenceAdapter::new(ml::dqn::dqn::DQNConfig {
state_dim: 51,
num_actions: 45,
hidden_dims: vec![64, 64],
..Default::default()
}) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"DQN".to_string(),
ml::ModelType::DQN,
));
if coordinator
.register_loaded_model("DQN".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create DQN adapter: {}", e),
}
// 2. PPO adapter (51-dim input, 3 actions: buy/sell/hold)
match ml::ensemble::adapters::PpoInferenceAdapter::new(ml::ppo::ppo::PPOConfig {
state_dim: 51,
num_actions: 3,
policy_hidden_dims: vec![64, 64],
value_hidden_dims: vec![64, 64],
..Default::default()
}) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"PPO".to_string(),
ml::ModelType::PPO,
));
if coordinator
.register_loaded_model("PPO".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create PPO adapter: {}", e),
}
// 3. TFT adapter (51-dim split: 3 static + 8 known + 40 unknown)
match ml::ensemble::adapters::TftInferenceAdapter::new(
ml::tft::TFTConfig {
input_dim: 51,
hidden_dim: 32,
num_heads: 4,
num_layers: 1,
prediction_horizon: 1,
sequence_length: 10,
num_quantiles: 3,
num_static_features: 3,
num_known_features: 8,
num_unknown_features: 40,
..Default::default()
},
10,
) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"TFT".to_string(),
ml::ModelType::TFT,
));
if coordinator
.register_loaded_model("TFT".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create TFT adapter: {}", e),
}
// 4. Mamba2 adapter (d_model=64, zero-pads from 51-dim features)
match ml::ensemble::adapters::Mamba2InferenceAdapter::new(
ml::mamba::Mamba2Config {
d_model: 64,
d_state: 16,
d_head: 16,
num_heads: 2,
expand: 1,
num_layers: 1,
..Default::default()
},
10,
) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"MAMBA2".to_string(),
ml::ModelType::MAMBA,
));
if coordinator
.register_loaded_model("MAMBA2".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create MAMBA2 adapter: {}", e),
}
// 5. Liquid CfC adapter (51-dim input, 3 output: buy/hold/sell)
match ml::ensemble::adapters::LiquidInferenceAdapter::new(
ml::liquid::candle_cfc::CfCTrainConfig {
input_size: 51,
hidden_size: 32,
output_size: 3,
backbone_hidden_sizes: vec![32],
seq_len: 1,
device: ml::liquid::candle_cfc::DeviceConfig::Cpu,
..ml::liquid::candle_cfc::CfCTrainConfig::default()
},
) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"Liquid-CfC".to_string(),
ml::ModelType::LNN,
));
if coordinator
.register_loaded_model("Liquid-CfC".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create Liquid-CfC adapter: {}", e),
}
// 6. TGGN adapter (51-dim input, 2-layer candle projection)
match ml::ensemble::adapters::TggnInferenceAdapter::new(51, 64) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"TGGN".to_string(),
ml::ModelType::TGGN,
));
if coordinator
.register_loaded_model("TGGN".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create TGGN adapter: {}", e),
}
// 7. TLOB adapter (51-dim features, 3-layer MLP, seq_len=10)
match ml::ensemble::adapters::TlobInferenceAdapter::new(51, 64, 10) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"TLOB".to_string(),
ml::ModelType::TLOB,
));
if coordinator
.register_loaded_model("TLOB".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create TLOB adapter: {}", e),
}
// 8. KAN adapter (51-dim input, B-spline activations)
match ml::ensemble::adapters::KanInferenceAdapter::new(ml::kan::KANConfig {
layer_widths: vec![51, 32, 16, 1],
..Default::default()
}) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"KAN".to_string(),
ml::ModelType::KAN,
));
if coordinator
.register_loaded_model("KAN".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create KAN adapter: {}", e),
}
// 9. xLSTM adapter (51-dim input, sLSTM+mLSTM, seq_len=10)
match ml::ensemble::adapters::XlstmInferenceAdapter::new(
ml::xlstm::XLSTMConfig {
input_dim: 51,
hidden_dim: 64,
num_blocks: 2,
num_heads: 2,
..Default::default()
},
10,
) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"xLSTM".to_string(),
ml::ModelType::XLSTM,
));
if coordinator
.register_loaded_model("xLSTM".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create xLSTM adapter: {}", e),
}
// 10. Diffusion adapter (51-dim features, DDPM denoiser, t=1 inference)
match ml::ensemble::adapters::DiffusionInferenceAdapter::new(ml::diffusion::DiffusionConfig {
seq_len: 1,
feature_dim: 51,
hidden_dim: 64,
num_layers: 2,
time_embed_dim: 32,
num_timesteps: 100,
sampling_steps: 5,
..Default::default()
}) {
Ok(adapter) => {
let bridge = Arc::new(InferenceAdapterBridge::new(
Box::new(adapter),
"Diffusion".to_string(),
ml::ModelType::Diffusion,
));
if coordinator
.register_loaded_model("Diffusion".to_string(), bridge, 0.10)
.await
.is_ok()
{
registered += 1;
}
}
Err(e) => eprintln!("Failed to create Diffusion adapter: {}", e),
}
registered
}
/// End-to-end pipeline test: OHLCV -> features -> ensemble (10 models) -> prediction.
///
/// This test proves the critical trading pipeline works in-process with no
/// external dependencies. All 10 candle-backed inference adapters are
/// initialised with random weights and fed synthetic market data.
#[tokio::test(flavor = "multi_thread")]
async fn test_e2e_ohlcv_to_prediction_10_models() {
// ---- Step 1: Create ensemble coordinator and register all 10 models ----
let coordinator = EnsembleCoordinator::new();
let registered = register_all_10_adapters(&coordinator).await;
let model_count = coordinator.model_count().await;
println!(
"[E2E] Registered {}/{} adapters (coordinator reports {} models)",
registered, 10, model_count,
);
assert_eq!(
model_count, 10,
"All 10 inference adapters must be registered"
);
// ---- Step 2: Feed 60 synthetic OHLCV bars to warm up feature extractors ----
let symbol = "ES.FUT";
let base_time = Utc::now();
let base_price = 4500.0_f64;
for i in 0..60 {
// Gentle uptrend with small noise
let price = base_price + (i as f64) * 0.25;
let volume = 10_000.0 + (i as f64) * 50.0;
let ts = base_time + chrono::Duration::seconds(i * 60);
coordinator
.update_market_data(symbol, price, volume, ts)
.await
.unwrap_or_else(|e| panic!("update_market_data failed at bar {}: {}", i, e));
}
println!("[E2E] Fed 60 OHLCV bars for {}", symbol);
// ---- Step 3: Extract features (should be real 51-dim after warmup) ----
let features = coordinator
.fetch_features_for_symbol(symbol)
.await
.unwrap_or_else(|e| panic!("fetch_features_for_symbol failed: {}", e));
assert_eq!(
features.values.len(),
51,
"Feature vector must be 51-dimensional"
);
assert_eq!(features.names.len(), 51, "Feature names must be 51");
assert_eq!(features.symbol.as_deref(), Some(symbol));
// After warmup, at least some features should be non-zero
let non_zero = features.values.iter().filter(|v| v.abs() > 1e-12).count();
println!(
"[E2E] Feature extraction: {}/51 non-zero features",
non_zero
);
assert!(
non_zero > 5,
"Expected many non-zero features after 60-bar warmup, got {}",
non_zero,
);
// All features must be finite
for (idx, &val) in features.values.iter().enumerate() {
assert!(
val.is_finite(),
"Feature {} ({}) is NaN/Inf: {}",
idx,
features.names.get(idx).map(|s| s.as_str()).unwrap_or("?"),
val,
);
}
// ---- Step 4: Run ensemble prediction ----
let decision = coordinator
.predict(&features)
.await
.unwrap_or_else(|e| panic!("Ensemble prediction failed: {}", e));
println!(
"[E2E] Ensemble decision: action={:?}, confidence={:.4}, signal={:.4}, \
disagreement={:.4}, model_votes={}",
decision.action,
decision.confidence,
decision.signal,
decision.disagreement_rate,
decision.model_count(),
);
// Print per-model votes for debugging
for (model_id, vote) in &decision.model_votes {
println!(
" [{}] signal={:.4}, confidence={:.4}, weight={:.4}",
model_id, vote.signal, vote.confidence, vote.weight,
);
}
// ---- Step 5: Assertions ----
// Confidence must be in [0.0, 1.0]
assert!(
decision.confidence >= 0.0 && decision.confidence <= 1.0,
"Confidence {:.4} must be in [0.0, 1.0]",
decision.confidence,
);
// Action must be Buy, Sell, or Hold (exhaustive match proves it)
match decision.action {
ml::ensemble::TradingAction::Buy
| ml::ensemble::TradingAction::Sell
| ml::ensemble::TradingAction::Hold => {}
}
// Model count must be 10 (all models contributed a vote)
assert_eq!(
decision.model_count(),
10,
"All 10 models should have contributed votes, got {}",
decision.model_count(),
);
// Signal must be finite
assert!(
decision.signal.is_finite(),
"Signal must be finite, got {}",
decision.signal,
);
// Disagreement rate must be in [0.0, 1.0]
assert!(
decision.disagreement_rate >= 0.0 && decision.disagreement_rate <= 1.0,
"Disagreement rate {:.4} must be in [0.0, 1.0]",
decision.disagreement_rate,
);
println!("[E2E] All assertions passed -- pipeline is wired end-to-end!");
}