- Remove ALL #[cfg(feature = "cuda")] guards (~400+ occurrences) - Remove ALL #[cfg_attr(not(feature = "cuda"), ignore)] test annotations (~250) - Make cuda default feature in 9 ML crates (ml, ml-core, ml-dqn, ml-ppo, etc.) - Convert nvrtc JIT compilation to precompiled nvcc (searchsorted, prefix_sum) - Move compile_ptx_for_device() to ml-core for shared access - Delete dead CPU code: multi_step.rs, self_supervised_pretraining.rs, training_guard_gpu_tests.rs, CPU PER buffer paths, CPU Q-diagnostics - Replace unwrap_or(Device::Cpu) with hard errors everywhere - Remove dead is_cuda() else branches in DQN/PPO/hyperopt trainers - Change config defaults from "cpu" to "cuda" (rainbow, tlob, pipeline) - Port IQL value network to GPU kernel (5 CUDA entry points) - Port HER goal relabeling to GPU kernel (warp-per-sample) - Wire DSR GPU-to-CPU sync in training loop - cfg!(feature = "cuda") → true in inference_validator Zero warnings, zero errors across entire workspace. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1297 lines
43 KiB
Rust
1297 lines
43 KiB
Rust
//! Ensemble Coordinator for Production Trading
|
|
//!
|
|
//! This module implements the core ensemble coordinator that aggregates predictions
|
|
//! from multiple ML models (DQN, PPO, TFT, MAMBA-2, Liquid, TLOB) for production trading decisions.
|
|
//! Supports dynamic weighting based on performance and model diversity metrics.
|
|
|
|
use crate::conviction_gates::{
|
|
ConvictionGateConfig, ConvictionGateEvaluator, ConvictionGateOutcome, GateInput, TradingSession,
|
|
};
|
|
use crate::inference_adapter::{FeatureVector, ModelInferenceAdapter};
|
|
use crate::{EnsembleDecision, ModelVote, ModelWeight, TradingAction};
|
|
use crate::{Features, MLError, MLResult, ModelPrediction};
|
|
use chrono::{DateTime, Timelike, Utc};
|
|
use chrono_tz::America::New_York;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Minimum weight threshold per model (5%)
|
|
const MIN_WEIGHT_THRESHOLD: f64 = 0.05;
|
|
|
|
/// Maximum weight per model (40%) to prevent dominance
|
|
const MAX_WEIGHT_THRESHOLD: f64 = 0.40;
|
|
|
|
/// Ensemble coordinator for aggregating model predictions
|
|
pub struct EnsembleCoordinator {
|
|
/// Active model registry (dual-buffer for hot-swapping)
|
|
active_models: Arc<RwLock<ModelRegistry>>,
|
|
|
|
/// Signal aggregator
|
|
aggregator: Arc<SignalAggregator>,
|
|
|
|
/// Model weights configuration
|
|
model_weights: Arc<RwLock<HashMap<String, ModelWeight>>>,
|
|
|
|
/// Configuration for ensemble behavior
|
|
config: EnsembleConfig,
|
|
|
|
/// Real model inference adapters for production predictions
|
|
adapters: Vec<Box<dyn ModelInferenceAdapter>>,
|
|
|
|
/// Conviction gate evaluator (optional -- None means no gating)
|
|
conviction_gates: Option<ConvictionGateEvaluator>,
|
|
}
|
|
|
|
impl std::fmt::Debug for EnsembleCoordinator {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("EnsembleCoordinator")
|
|
.field("config", &self.config)
|
|
.field("adapter_count", &self.adapters.len())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Configuration for ensemble coordinator
|
|
#[derive(Debug, Clone)]
|
|
pub struct EnsembleConfig {
|
|
/// Enable adaptive weighting based on correlation
|
|
pub adaptive_weighting: bool,
|
|
|
|
/// Minimum correlation threshold for diversity bonus
|
|
pub min_correlation_for_diversity: f64,
|
|
|
|
/// Weight adjustment factor for diversity
|
|
pub diversity_weight_factor: f64,
|
|
|
|
/// Performance window size (number of predictions)
|
|
pub performance_window_size: usize,
|
|
}
|
|
|
|
impl EnsembleCoordinator {
|
|
/// Create new ensemble coordinator
|
|
pub fn new() -> Self {
|
|
let config = EnsembleConfig {
|
|
adaptive_weighting: true,
|
|
min_correlation_for_diversity: 0.3,
|
|
diversity_weight_factor: 1.2,
|
|
performance_window_size: 100,
|
|
};
|
|
|
|
Self {
|
|
active_models: Arc::new(RwLock::new(ModelRegistry::new())),
|
|
aggregator: Arc::new(SignalAggregator::new()),
|
|
model_weights: Arc::new(RwLock::new(HashMap::new())),
|
|
config,
|
|
adapters: Vec::new(),
|
|
conviction_gates: Some(ConvictionGateEvaluator::new(ConvictionGateConfig {
|
|
// Allow all sessions by default -- trading service narrows to Regular
|
|
allowed_sessions: vec![
|
|
TradingSession::PreMarket,
|
|
TradingSession::Regular,
|
|
TradingSession::AfterHours,
|
|
],
|
|
..ConvictionGateConfig::default()
|
|
})),
|
|
}
|
|
}
|
|
|
|
/// Replace conviction gate configuration
|
|
pub fn set_conviction_gates(&mut self, config: ConvictionGateConfig) {
|
|
self.conviction_gates = Some(ConvictionGateEvaluator::new(config));
|
|
}
|
|
|
|
/// Get mutable reference to conviction gate evaluator
|
|
pub fn conviction_gates_mut(&mut self) -> Option<&mut ConvictionGateEvaluator> {
|
|
self.conviction_gates.as_mut()
|
|
}
|
|
|
|
/// Add a real model inference adapter to the ensemble.
|
|
/// When an adapter's model_name matches a registered model, its predictions
|
|
/// are used for ensemble inference.
|
|
pub fn add_adapter(&mut self, adapter: Box<dyn ModelInferenceAdapter>) {
|
|
info!("Added inference adapter: {}", adapter.model_name());
|
|
self.adapters.push(adapter);
|
|
}
|
|
|
|
/// Register a model in the ensemble
|
|
pub async fn register_model(&self, model_id: String, weight: f64) -> MLResult<()> {
|
|
let model_weight = ModelWeight::new(model_id.clone(), weight);
|
|
|
|
let mut weights = self.model_weights.write().await;
|
|
weights.insert(model_id.clone(), model_weight);
|
|
|
|
info!("Registered model {} with weight {}", model_id, weight);
|
|
Ok(())
|
|
}
|
|
|
|
/// Make ensemble prediction from features
|
|
///
|
|
/// Returns an error if no model adapters are registered or all adapters fail inference.
|
|
/// Production trading must never rely on simulated/mock predictions.
|
|
pub async fn predict(&self, features: &Features) -> MLResult<EnsembleDecision> {
|
|
if self.adapters.is_empty() {
|
|
return Err(MLError::ModelError(
|
|
"No model adapters registered".into(),
|
|
));
|
|
}
|
|
|
|
debug!(
|
|
"Making ensemble prediction with {} features",
|
|
features.values.len()
|
|
);
|
|
|
|
let predictions = self.generate_predictions(features).await?;
|
|
|
|
if predictions.is_empty() {
|
|
return Err(MLError::ModelError(
|
|
"All model adapters failed inference".into(),
|
|
));
|
|
}
|
|
|
|
// Aggregate predictions
|
|
let decision = self
|
|
.aggregator
|
|
.aggregate(predictions, &*self.model_weights.read().await)
|
|
.await?;
|
|
|
|
// Apply conviction gates if configured
|
|
if let Some(ref gates) = self.conviction_gates {
|
|
let gate_input = GateInput {
|
|
confidence: decision.confidence,
|
|
disagreement_rate: decision.disagreement_rate,
|
|
quorum_ratio: Self::calculate_quorum_ratio(&decision),
|
|
healthy_models: self.adapters.iter().filter(|a| a.is_ready()).count(),
|
|
total_models: self.adapters.len(),
|
|
current_session: Self::current_trading_session(),
|
|
regime_volatility: Self::extract_regime_volatility(features),
|
|
};
|
|
|
|
match gates.evaluate(&gate_input) {
|
|
ConvictionGateOutcome::Passed(pass_result) => {
|
|
info!(
|
|
"Conviction gates passed: score={:.3}, {} gates evaluated",
|
|
pass_result.conviction_score,
|
|
pass_result.gate_details.len()
|
|
);
|
|
let mut gated_decision = decision;
|
|
gated_decision.metadata.insert(
|
|
"conviction_score".into(),
|
|
serde_json::Value::from(pass_result.conviction_score),
|
|
);
|
|
return Ok(gated_decision);
|
|
}
|
|
ConvictionGateOutcome::Rejected(rejection) => {
|
|
info!("Conviction gate rejected: {:?} -- forcing HOLD", rejection);
|
|
let mut hold_decision = EnsembleDecision::new(
|
|
TradingAction::Hold,
|
|
decision.confidence,
|
|
0.0,
|
|
decision.disagreement_rate,
|
|
decision.model_votes,
|
|
);
|
|
hold_decision.metadata.insert(
|
|
"gate_rejection".into(),
|
|
serde_json::Value::String(format!("{:?}", rejection)),
|
|
);
|
|
return Ok(hold_decision);
|
|
}
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Ensemble decision: {:?}, confidence: {:.3}, disagreement: {:.3}",
|
|
decision.action, decision.confidence, decision.disagreement_rate
|
|
);
|
|
|
|
Ok(decision)
|
|
}
|
|
|
|
/// Generate predictions from real ML model adapters
|
|
///
|
|
/// Iterates over registered models and attempts inference via their adapters.
|
|
/// Models without a ready adapter or whose adapter fails are skipped --
|
|
/// no mock/simulated predictions are generated on the production path.
|
|
async fn generate_predictions(
|
|
&self,
|
|
features: &Features,
|
|
) -> MLResult<Vec<ModelPrediction>> {
|
|
let model_ids: Vec<String> = {
|
|
let weights = self.model_weights.read().await;
|
|
weights.keys().cloned().collect()
|
|
};
|
|
|
|
let mut predictions = Vec::new();
|
|
|
|
for model_id in model_ids {
|
|
if let Some(adapter) = self
|
|
.adapters
|
|
.iter()
|
|
.find(|a| a.model_name() == model_id && a.is_ready())
|
|
{
|
|
let fv = FeatureVector {
|
|
values: features.values.clone(),
|
|
timestamp: features.timestamp as i64,
|
|
};
|
|
match adapter.predict(&fv) {
|
|
Ok(ensemble_pred) => {
|
|
if !ensemble_pred.direction.is_finite()
|
|
|| !ensemble_pred.confidence.is_finite()
|
|
{
|
|
warn!(
|
|
"Adapter {} returned non-finite prediction \
|
|
(direction={}, confidence={}), skipping",
|
|
model_id, ensemble_pred.direction, ensemble_pred.confidence
|
|
);
|
|
continue;
|
|
}
|
|
let prediction = ModelPrediction::new(
|
|
model_id.clone(),
|
|
ensemble_pred.direction,
|
|
ensemble_pred.confidence,
|
|
);
|
|
predictions.push(prediction);
|
|
}
|
|
Err(e) => {
|
|
warn!(
|
|
"Adapter {} inference failed, skipping: {}",
|
|
model_id, e
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
warn!(
|
|
"No ready adapter for model {}, skipping",
|
|
model_id
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(predictions)
|
|
}
|
|
|
|
/// Check whether any registered adapter is ready for inference
|
|
pub fn has_models(&self) -> bool {
|
|
self.adapters.iter().any(|a| a.is_ready())
|
|
}
|
|
|
|
/// Update model weights based on performance
|
|
pub async fn update_model_weights(&self) -> MLResult<()> {
|
|
let mut weights = self.model_weights.write().await;
|
|
|
|
for weight in weights.values_mut() {
|
|
weight.update_dynamic_weight();
|
|
}
|
|
|
|
debug!("Updated dynamic weights for {} models", weights.len());
|
|
Ok(())
|
|
}
|
|
|
|
/// Get model count
|
|
pub async fn model_count(&self) -> usize {
|
|
self.model_weights.read().await.len()
|
|
}
|
|
|
|
/// Load PPO model from production checkpoint (Agent 170 validated)
|
|
///
|
|
/// Example:
|
|
/// ```ignore
|
|
/// coordinator.load_ppo_checkpoint(
|
|
/// "PPO_epoch420",
|
|
/// "ml/trained_models/production/ppo/ppo_actor_epoch_420.safetensors",
|
|
/// "ml/trained_models/production/ppo/ppo_critic_epoch_420.safetensors",
|
|
/// 0.33,
|
|
/// ).await?;
|
|
/// ```
|
|
pub async fn load_ppo_checkpoint(
|
|
&self,
|
|
model_id: &str,
|
|
actor_checkpoint: &str,
|
|
critic_checkpoint: &str,
|
|
weight: f64,
|
|
) -> MLResult<()> {
|
|
info!(
|
|
"Loading PPO checkpoint: actor={}, critic={}",
|
|
actor_checkpoint, critic_checkpoint
|
|
);
|
|
|
|
// Stage checkpoints in registry (both actor and critic as single entry)
|
|
let mut registry = self.active_models.write().await;
|
|
registry.stage_checkpoint(
|
|
model_id.to_string(),
|
|
format!("actor={},critic={}", actor_checkpoint, critic_checkpoint),
|
|
);
|
|
registry.commit_swap(model_id)?;
|
|
drop(registry);
|
|
|
|
// Register model with weight
|
|
self.register_model(model_id.to_string(), weight).await?;
|
|
|
|
info!(
|
|
"✅ PPO checkpoint loaded and registered: {} (weight: {:.2})",
|
|
model_id, weight
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load TFT model from production checkpoint (BF16 precision)
|
|
///
|
|
/// Example:
|
|
/// ```ignore
|
|
/// coordinator.load_tft_checkpoint(
|
|
/// "TFT",
|
|
/// "ml/trained_models/production/tft/tft_epoch_200.safetensors",
|
|
/// 0.15,
|
|
/// ).await?;
|
|
/// ```
|
|
pub async fn load_tft_checkpoint(
|
|
&self,
|
|
model_id: &str,
|
|
checkpoint: &str,
|
|
weight: f64,
|
|
) -> MLResult<()> {
|
|
info!("Loading TFT checkpoint: {}", checkpoint);
|
|
|
|
// Stage checkpoint in registry
|
|
let mut registry = self.active_models.write().await;
|
|
registry.stage_checkpoint(model_id.to_string(), checkpoint.to_string());
|
|
registry.commit_swap(model_id)?;
|
|
drop(registry);
|
|
|
|
// Register model with weight
|
|
self.register_model(model_id.to_string(), weight).await?;
|
|
|
|
info!(
|
|
"TFT checkpoint loaded and registered: {} (weight: {:.2})",
|
|
model_id, weight
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Calculate quorum ratio (fraction of models agreeing on majority direction)
|
|
fn calculate_quorum_ratio(decision: &EnsembleDecision) -> f64 {
|
|
if decision.model_votes.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let majority_action = &decision.action;
|
|
let agreeing = decision
|
|
.model_votes
|
|
.values()
|
|
.filter(|v| TradingAction::from_signal(v.signal, 0.3) == *majority_action)
|
|
.count();
|
|
agreeing as f64 / decision.model_votes.len() as f64
|
|
}
|
|
|
|
/// Determine current trading session (Eastern Time, DST-aware)
|
|
fn current_trading_session() -> TradingSession {
|
|
Self::trading_session_at(Utc::now())
|
|
}
|
|
|
|
/// Determine trading session for an arbitrary UTC timestamp.
|
|
///
|
|
/// Uses `chrono-tz` America/New_York so the boundaries are correct
|
|
/// during both EST (UTC-5, Nov-Mar) and EDT (UTC-4, Mar-Nov).
|
|
fn trading_session_at(utc_time: DateTime<Utc>) -> TradingSession {
|
|
let et = utc_time.with_timezone(&New_York);
|
|
let et_time = et.hour() * 60 + et.minute();
|
|
|
|
match et_time {
|
|
t if t < 240 => TradingSession::AfterHours, // 00:00-04:00 ET
|
|
t if t < 570 => TradingSession::PreMarket, // 04:00-09:30 ET
|
|
t if t < 960 => TradingSession::Regular, // 09:30-16:00 ET
|
|
_ => TradingSession::AfterHours, // 16:00-24:00 ET
|
|
}
|
|
}
|
|
|
|
/// Extract regime volatility from feature vector
|
|
/// Regime features are at indices 48-50 in the 51-dim standard feature vector
|
|
fn extract_regime_volatility(features: &Features) -> f64 {
|
|
features
|
|
.values
|
|
.get(48)
|
|
.copied()
|
|
.unwrap_or(0.01)
|
|
.abs()
|
|
}
|
|
}
|
|
|
|
impl Default for EnsembleCoordinator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Model registry with dual-buffer support for hot-swapping
|
|
#[derive(Debug)]
|
|
pub struct ModelRegistry {
|
|
/// Active models (currently serving predictions)
|
|
active: HashMap<String, String>,
|
|
|
|
/// Shadow models (staged for hot-swap)
|
|
shadow: HashMap<String, String>,
|
|
}
|
|
|
|
impl ModelRegistry {
|
|
/// Create new model registry
|
|
pub fn new() -> Self {
|
|
Self {
|
|
active: HashMap::new(),
|
|
shadow: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Stage a checkpoint in shadow buffer
|
|
pub fn stage_checkpoint(&mut self, model_id: String, checkpoint_path: String) {
|
|
self.shadow
|
|
.insert(model_id.clone(), checkpoint_path.clone());
|
|
info!(
|
|
"Staged checkpoint {} for model {}",
|
|
checkpoint_path, model_id
|
|
);
|
|
}
|
|
|
|
/// Commit swap (shadow becomes active)
|
|
pub fn commit_swap(&mut self, model_id: &str) -> MLResult<()> {
|
|
if let Some(shadow_path) = self.shadow.remove(model_id) {
|
|
let old_path = self
|
|
.active
|
|
.insert(model_id.to_string(), shadow_path);
|
|
|
|
if let Some(old) = old_path {
|
|
// Move old to shadow for potential rollback
|
|
self.shadow.insert(model_id.to_string(), old);
|
|
}
|
|
|
|
info!("Committed checkpoint swap for model {}", model_id);
|
|
Ok(())
|
|
} else {
|
|
Err(MLError::ModelNotFound(format!(
|
|
"No staged checkpoint for model {}",
|
|
model_id
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Rollback to previous checkpoint
|
|
pub fn rollback(&mut self, model_id: &str) -> MLResult<()> {
|
|
if let Some(previous_path) = self.shadow.remove(model_id) {
|
|
self.active.insert(model_id.to_string(), previous_path);
|
|
warn!("Rolled back model {} to previous checkpoint", model_id);
|
|
Ok(())
|
|
} else {
|
|
Err(MLError::ModelNotFound(format!(
|
|
"No previous checkpoint for model {}",
|
|
model_id
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ModelRegistry {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Signal aggregator for ensemble predictions
|
|
#[derive(Debug)]
|
|
pub struct SignalAggregator {
|
|
/// Signal threshold for Buy/Sell actions
|
|
signal_threshold: f64,
|
|
|
|
/// Minimum confidence for high-confidence decisions
|
|
min_confidence: f64,
|
|
}
|
|
|
|
impl SignalAggregator {
|
|
/// Create new signal aggregator
|
|
pub fn new() -> Self {
|
|
Self {
|
|
signal_threshold: 0.3,
|
|
min_confidence: 0.6,
|
|
}
|
|
}
|
|
|
|
/// Aggregate model predictions into ensemble decision
|
|
pub async fn aggregate(
|
|
&self,
|
|
predictions: Vec<ModelPrediction>,
|
|
weights: &HashMap<String, ModelWeight>,
|
|
) -> MLResult<EnsembleDecision> {
|
|
if predictions.is_empty() {
|
|
return Err(MLError::ValidationError {
|
|
message: "No predictions to aggregate".to_owned(),
|
|
});
|
|
}
|
|
|
|
// Normalize effective weights so they sum to 1.0 before any calculations.
|
|
// Raw effective_weight = static_weight * dynamic_weight can range 0.5-1.5
|
|
// per model, so the sum across models is not guaranteed to be 1.0.
|
|
let normalized = Self::normalize_effective_weights(&predictions, weights);
|
|
|
|
// Calculate weighted average signal
|
|
let (weighted_signal, _total_weight) =
|
|
self.calculate_weighted_signal(&predictions, &normalized);
|
|
|
|
// Calculate ensemble confidence
|
|
let confidence = self.calculate_ensemble_confidence(&predictions, &normalized);
|
|
|
|
// Calculate disagreement rate
|
|
let disagreement_rate = self.calculate_disagreement_rate(&predictions);
|
|
|
|
// Determine trading action
|
|
let action = TradingAction::from_signal(weighted_signal, self.signal_threshold);
|
|
|
|
// Build model votes (uses normalized weights)
|
|
let model_votes = self.build_model_votes(&predictions, &normalized);
|
|
|
|
let decision = EnsembleDecision::new(
|
|
action,
|
|
confidence,
|
|
weighted_signal,
|
|
disagreement_rate,
|
|
model_votes,
|
|
);
|
|
|
|
Ok(decision)
|
|
}
|
|
|
|
/// Normalize effective weights across all predictions so they sum to 1.0.
|
|
///
|
|
/// Models without an explicit weight entry receive equal share (1/N).
|
|
/// If total weight is zero or non-finite, all models get uniform 1/N weight.
|
|
fn normalize_effective_weights(
|
|
predictions: &[ModelPrediction],
|
|
weights: &HashMap<String, ModelWeight>,
|
|
) -> HashMap<String, f64> {
|
|
let n = predictions.len();
|
|
let uniform = if n > 0 { 1.0 / n as f64 } else { 0.0 };
|
|
|
|
// Collect raw effective weights
|
|
let raw: Vec<(String, f64)> = predictions
|
|
.iter()
|
|
.map(|pred| {
|
|
let w = weights
|
|
.get(&pred.model_id)
|
|
.map(|mw| mw.effective_weight())
|
|
.unwrap_or(uniform);
|
|
(pred.model_id.clone(), w)
|
|
})
|
|
.collect();
|
|
|
|
let total: f64 = raw.iter().map(|(_, w)| w).sum();
|
|
|
|
let mut normalized = HashMap::new();
|
|
if total > 0.0 && total.is_finite() {
|
|
for (id, w) in raw {
|
|
normalized.insert(id, w / total);
|
|
}
|
|
} else {
|
|
// Fallback: uniform weights
|
|
for (id, _) in raw {
|
|
normalized.insert(id, uniform);
|
|
}
|
|
}
|
|
|
|
normalized
|
|
}
|
|
|
|
/// Calculate weighted average signal using pre-normalized weights
|
|
fn calculate_weighted_signal(
|
|
&self,
|
|
predictions: &[ModelPrediction],
|
|
normalized_weights: &HashMap<String, f64>,
|
|
) -> (f64, f64) {
|
|
let n = predictions.len();
|
|
let uniform = if n > 0 { 1.0 / n as f64 } else { 0.0 };
|
|
let mut weighted_sum = 0.0;
|
|
let mut total_weight = 0.0;
|
|
|
|
for pred in predictions {
|
|
let weight = normalized_weights
|
|
.get(&pred.model_id)
|
|
.copied()
|
|
.unwrap_or(uniform);
|
|
|
|
weighted_sum += pred.value * pred.confidence * weight;
|
|
total_weight += weight * pred.confidence;
|
|
}
|
|
|
|
let signal = if total_weight > 0.0 {
|
|
weighted_sum / total_weight
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
(signal, total_weight)
|
|
}
|
|
|
|
/// Calculate ensemble confidence using pre-normalized weights
|
|
fn calculate_ensemble_confidence(
|
|
&self,
|
|
predictions: &[ModelPrediction],
|
|
normalized_weights: &HashMap<String, f64>,
|
|
) -> f64 {
|
|
let n = predictions.len();
|
|
let uniform = if n > 0 { 1.0 / n as f64 } else { 0.0 };
|
|
let mut confidence_sum = 0.0;
|
|
let mut weight_sum = 0.0;
|
|
|
|
for pred in predictions {
|
|
let weight = normalized_weights
|
|
.get(&pred.model_id)
|
|
.copied()
|
|
.unwrap_or(uniform);
|
|
|
|
confidence_sum += pred.confidence * weight;
|
|
weight_sum += weight;
|
|
}
|
|
|
|
if weight_sum > 0.0 {
|
|
confidence_sum / weight_sum
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
/// Calculate disagreement rate (% models disagree with ensemble)
|
|
fn calculate_disagreement_rate(&self, predictions: &[ModelPrediction]) -> f64 {
|
|
if predictions.len() < 2 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Calculate mean signal
|
|
let mean_signal: f64 =
|
|
predictions.iter().map(|p| p.value).sum::<f64>() / predictions.len() as f64;
|
|
|
|
// Count models with opposite sign from mean
|
|
let disagreements = predictions
|
|
.iter()
|
|
.filter(|p| (p.value * mean_signal) < 0.0)
|
|
.count();
|
|
|
|
disagreements as f64 / predictions.len() as f64
|
|
}
|
|
|
|
/// Build model votes map using pre-normalized weights
|
|
fn build_model_votes(
|
|
&self,
|
|
predictions: &[ModelPrediction],
|
|
normalized_weights: &HashMap<String, f64>,
|
|
) -> HashMap<String, ModelVote> {
|
|
let n = predictions.len();
|
|
let uniform = if n > 0 { 1.0 / n as f64 } else { 0.0 };
|
|
let mut votes = HashMap::new();
|
|
|
|
for pred in predictions {
|
|
let weight = normalized_weights
|
|
.get(&pred.model_id)
|
|
.copied()
|
|
.unwrap_or(uniform);
|
|
|
|
let vote = ModelVote::new(pred.model_id.clone(), pred.value, pred.confidence, weight);
|
|
|
|
votes.insert(pred.model_id.clone(), vote);
|
|
}
|
|
|
|
votes
|
|
}
|
|
}
|
|
|
|
impl Default for SignalAggregator {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(
|
|
unsafe_code,
|
|
clippy::undocumented_unsafe_blocks,
|
|
clippy::ok_expect,
|
|
clippy::for_kv_map
|
|
)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_coordinator_creation() {
|
|
let coordinator = EnsembleCoordinator::new();
|
|
assert_eq!(coordinator.model_count().await, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_models() {
|
|
let coordinator = EnsembleCoordinator::new();
|
|
|
|
coordinator
|
|
.register_model("DQN".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("PPO".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("TFT".to_owned(), 0.34)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(coordinator.model_count().await, 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_prediction() {
|
|
use crate::inference_adapter::{
|
|
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
|
|
};
|
|
|
|
struct StubAdapter {
|
|
name: &'static str,
|
|
direction: f64,
|
|
confidence: f64,
|
|
}
|
|
impl ModelInferenceAdapter for StubAdapter {
|
|
fn model_name(&self) -> &str {
|
|
self.name
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: self.name.to_string(),
|
|
direction: self.direction,
|
|
confidence: self.confidence,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
unsafe impl Send for StubAdapter {}
|
|
unsafe impl Sync for StubAdapter {}
|
|
|
|
let mut coordinator = EnsembleCoordinator::new();
|
|
coordinator.add_adapter(Box::new(StubAdapter { name: "DQN", direction: 0.6, confidence: 0.9 }));
|
|
coordinator.add_adapter(Box::new(StubAdapter { name: "PPO", direction: 0.5, confidence: 0.85 }));
|
|
coordinator.add_adapter(Box::new(StubAdapter { name: "TFT", direction: 0.4, confidence: 0.8 }));
|
|
|
|
coordinator
|
|
.register_model("DQN".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("PPO".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("TFT".to_owned(), 0.34)
|
|
.await
|
|
.unwrap();
|
|
|
|
let features = Features::new(
|
|
vec![0.5, 0.6, 0.7, 0.8, 0.9],
|
|
vec![
|
|
"f1".to_owned(),
|
|
"f2".to_owned(),
|
|
"f3".to_owned(),
|
|
"f4".to_owned(),
|
|
"f5".to_owned(),
|
|
],
|
|
);
|
|
|
|
let decision = coordinator.predict(&features).await.unwrap();
|
|
|
|
assert!(decision.confidence >= 0.0 && decision.confidence <= 1.0);
|
|
assert!(decision.signal >= -1.0 && decision.signal <= 1.0);
|
|
assert_eq!(decision.model_count(), 3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_disagreement_detection() {
|
|
let aggregator = SignalAggregator::new();
|
|
|
|
let predictions = vec![
|
|
ModelPrediction::new("DQN".to_owned(), 0.8, 0.9),
|
|
ModelPrediction::new("PPO".to_owned(), -0.7, 0.85),
|
|
ModelPrediction::new("TFT".to_owned(), 0.1, 0.8),
|
|
];
|
|
|
|
let weights = HashMap::new();
|
|
let decision = aggregator.aggregate(predictions, &weights).await.unwrap();
|
|
|
|
assert!(decision.disagreement_rate > 0.3);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_weighted_voting() {
|
|
let aggregator = SignalAggregator::new();
|
|
|
|
let predictions = vec![
|
|
ModelPrediction::new("DQN".to_owned(), 0.8, 0.9),
|
|
ModelPrediction::new("PPO".to_owned(), 0.7, 0.85),
|
|
ModelPrediction::new("TFT".to_owned(), 0.6, 0.8),
|
|
];
|
|
|
|
let mut weights = HashMap::new();
|
|
weights.insert("DQN".to_owned(), ModelWeight::new("DQN".to_owned(), 0.5));
|
|
weights.insert("PPO".to_owned(), ModelWeight::new("PPO".to_owned(), 0.3));
|
|
weights.insert("TFT".to_owned(), ModelWeight::new("TFT".to_owned(), 0.2));
|
|
|
|
let decision = aggregator.aggregate(predictions, &weights).await.unwrap();
|
|
|
|
assert_eq!(decision.action, TradingAction::Buy);
|
|
assert!(decision.signal > 0.6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_model_registry_swap() {
|
|
let mut registry = ModelRegistry::new();
|
|
|
|
registry.stage_checkpoint(
|
|
"DQN".to_owned(),
|
|
"checkpoint_epoch_100.safetensors".to_owned(),
|
|
);
|
|
|
|
registry.commit_swap("DQN").unwrap();
|
|
|
|
assert!(registry.active.contains_key("DQN"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_with_real_adapter() {
|
|
use crate::inference_adapter::{
|
|
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
|
|
};
|
|
|
|
struct TestAdapter;
|
|
impl ModelInferenceAdapter for TestAdapter {
|
|
fn model_name(&self) -> &str {
|
|
"DQN"
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: "DQN".to_owned(),
|
|
direction: 0.6,
|
|
confidence: 0.85,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
// Safety: TestAdapter has no mutable state, safe to share across threads
|
|
unsafe impl Send for TestAdapter {}
|
|
unsafe impl Sync for TestAdapter {}
|
|
|
|
let mut coordinator = EnsembleCoordinator::new();
|
|
coordinator.add_adapter(Box::new(TestAdapter));
|
|
coordinator
|
|
.register_model("DQN".to_owned(), 1.0)
|
|
.await
|
|
.unwrap();
|
|
|
|
let features = Features::new(
|
|
vec![0.5; 10],
|
|
vec!["f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
);
|
|
let decision = coordinator.predict(&features).await.unwrap();
|
|
assert!(decision.confidence > 0.0);
|
|
assert_eq!(decision.model_count(), 1);
|
|
}
|
|
|
|
// NOTE: test_full_ensemble_with_dqn_adapter lives in ml crate (depends on DqnInferenceAdapter)
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_filters_nan_inf_predictions() {
|
|
use crate::inference_adapter::{
|
|
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
|
|
};
|
|
|
|
struct NanAdapter;
|
|
impl ModelInferenceAdapter for NanAdapter {
|
|
fn model_name(&self) -> &str {
|
|
"NAN_MODEL"
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: "NAN_MODEL".to_owned(),
|
|
direction: f64::NAN,
|
|
confidence: 0.9,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
unsafe impl Send for NanAdapter {}
|
|
unsafe impl Sync for NanAdapter {}
|
|
|
|
struct InfAdapter;
|
|
impl ModelInferenceAdapter for InfAdapter {
|
|
fn model_name(&self) -> &str {
|
|
"INF_MODEL"
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: "INF_MODEL".to_owned(),
|
|
direction: 0.5,
|
|
confidence: f64::INFINITY,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
unsafe impl Send for InfAdapter {}
|
|
unsafe impl Sync for InfAdapter {}
|
|
|
|
struct GoodAdapter;
|
|
impl ModelInferenceAdapter for GoodAdapter {
|
|
fn model_name(&self) -> &str {
|
|
"GOOD_MODEL"
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: "GOOD_MODEL".to_owned(),
|
|
direction: 0.6,
|
|
confidence: 0.85,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
unsafe impl Send for GoodAdapter {}
|
|
unsafe impl Sync for GoodAdapter {}
|
|
|
|
let mut coordinator = EnsembleCoordinator::new();
|
|
coordinator.add_adapter(Box::new(NanAdapter));
|
|
coordinator.add_adapter(Box::new(InfAdapter));
|
|
coordinator.add_adapter(Box::new(GoodAdapter));
|
|
|
|
coordinator
|
|
.register_model("NAN_MODEL".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("INF_MODEL".to_owned(), 0.33)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("GOOD_MODEL".to_owned(), 0.34)
|
|
.await
|
|
.unwrap();
|
|
|
|
let features = Features::new(
|
|
vec![0.5; 5],
|
|
vec!["f1", "f2", "f3", "f4", "f5"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
);
|
|
|
|
let decision = coordinator.predict(&features).await.unwrap();
|
|
|
|
// Only the good model should survive filtering
|
|
assert_eq!(
|
|
decision.model_count(),
|
|
1,
|
|
"NaN and Inf predictions should be filtered out"
|
|
);
|
|
assert!(
|
|
decision.confidence.is_finite(),
|
|
"Ensemble confidence must be finite"
|
|
);
|
|
assert!(
|
|
decision.signal.is_finite(),
|
|
"Ensemble signal must be finite"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_all_nan_returns_error() {
|
|
use crate::inference_adapter::{
|
|
EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta,
|
|
};
|
|
|
|
struct AllNanAdapter {
|
|
name: &'static str,
|
|
}
|
|
impl ModelInferenceAdapter for AllNanAdapter {
|
|
fn model_name(&self) -> &str {
|
|
self.name
|
|
}
|
|
fn predict(
|
|
&self,
|
|
_features: &FeatureVector,
|
|
) -> crate::MLResult<EnsemblePrediction> {
|
|
Ok(EnsemblePrediction {
|
|
model_name: self.name.to_string(),
|
|
direction: f64::NAN,
|
|
confidence: f64::NAN,
|
|
metadata: PredictionMeta::default(),
|
|
})
|
|
}
|
|
fn is_ready(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
unsafe impl Send for AllNanAdapter {}
|
|
unsafe impl Sync for AllNanAdapter {}
|
|
|
|
let mut coordinator = EnsembleCoordinator::new();
|
|
coordinator.add_adapter(Box::new(AllNanAdapter { name: "A" }));
|
|
coordinator.add_adapter(Box::new(AllNanAdapter { name: "B" }));
|
|
|
|
coordinator
|
|
.register_model("A".to_owned(), 0.5)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("B".to_owned(), 0.5)
|
|
.await
|
|
.unwrap();
|
|
|
|
let features = Features::new(
|
|
vec![0.5; 5],
|
|
vec!["f1", "f2", "f3", "f4", "f5"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
);
|
|
|
|
let result = coordinator.predict(&features).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"All-NaN predictions should produce an error"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_rejects_no_adapters() {
|
|
let coordinator = EnsembleCoordinator::new();
|
|
coordinator
|
|
.register_model("DQN".to_owned(), 0.5)
|
|
.await
|
|
.unwrap();
|
|
coordinator
|
|
.register_model("PPO".to_owned(), 0.5)
|
|
.await
|
|
.unwrap();
|
|
|
|
let features = Features::new(
|
|
vec![0.5; 5],
|
|
vec!["f1", "f2", "f3", "f4", "f5"]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect(),
|
|
);
|
|
let result = coordinator.predict(&features).await;
|
|
assert!(result.is_err(), "predict() must fail when no adapters are registered");
|
|
let err_msg = format!("{}", result.unwrap_err());
|
|
assert!(
|
|
err_msg.contains("No model adapters registered"),
|
|
"Expected 'No model adapters registered' error, got: {}",
|
|
err_msg
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_normalized_weights_sum_to_one() {
|
|
let aggregator = SignalAggregator::new();
|
|
|
|
let predictions = vec![
|
|
ModelPrediction::new("DQN".to_owned(), 0.8, 0.9),
|
|
ModelPrediction::new("PPO".to_owned(), 0.7, 0.85),
|
|
ModelPrediction::new("TFT".to_owned(), 0.6, 0.8),
|
|
];
|
|
|
|
// Create weights with dynamic adjustments that cause unnormalized sums.
|
|
// DQN: high perf => dynamic ~1.2, effective = 0.5 * 1.2 = 0.6
|
|
// PPO: low perf => dynamic ~0.6, effective = 0.3 * 0.6 = 0.18
|
|
// TFT: default => dynamic 1.0, effective = 0.2 * 1.0 = 0.2
|
|
// Raw sum = 0.98, NOT 1.0
|
|
let mut weights = HashMap::new();
|
|
|
|
let mut dqn_w = ModelWeight::new("DQN".to_owned(), 0.5);
|
|
dqn_w.performance_metrics.sharpe_ratio = 2.0;
|
|
dqn_w.performance_metrics.accuracy = 0.7;
|
|
dqn_w.update_dynamic_weight();
|
|
weights.insert("DQN".to_owned(), dqn_w);
|
|
|
|
let mut ppo_w = ModelWeight::new("PPO".to_owned(), 0.3);
|
|
ppo_w.performance_metrics.sharpe_ratio = 0.3;
|
|
ppo_w.performance_metrics.accuracy = 0.3;
|
|
ppo_w.update_dynamic_weight();
|
|
weights.insert("PPO".to_owned(), ppo_w);
|
|
|
|
let mut tft_w = ModelWeight::new("TFT".to_owned(), 0.2);
|
|
// Keep default performance (sharpe=1.0, accuracy=0.5) => dynamic ~0.95
|
|
tft_w.update_dynamic_weight();
|
|
weights.insert("TFT".to_owned(), tft_w);
|
|
|
|
// Verify raw effective weights do NOT sum to 1.0
|
|
let raw_sum: f64 = weights.values().map(|w| w.effective_weight()).sum();
|
|
assert!(
|
|
(raw_sum - 1.0).abs() > 0.01,
|
|
"Raw effective weights should NOT sum to 1.0 (got {}), \
|
|
otherwise the test does not exercise normalization",
|
|
raw_sum
|
|
);
|
|
|
|
// After aggregation, model_votes weights must sum to 1.0
|
|
let decision = aggregator
|
|
.aggregate(predictions, &weights)
|
|
.await
|
|
.ok()
|
|
.expect("aggregation should succeed");
|
|
|
|
let vote_weight_sum: f64 = decision.model_votes.values().map(|v| v.weight).sum();
|
|
assert!(
|
|
(vote_weight_sum - 1.0).abs() < 1e-10,
|
|
"Normalized vote weights must sum to 1.0, got {}",
|
|
vote_weight_sum
|
|
);
|
|
|
|
// Each individual weight must be positive
|
|
for (model_id, vote) in &decision.model_votes {
|
|
assert!(
|
|
vote.weight > 0.0,
|
|
"Weight for {} must be positive, got {}",
|
|
model_id,
|
|
vote.weight
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_effective_weights_uniform_fallback() {
|
|
// When weights map is empty, all models get 1/N
|
|
let predictions = vec![
|
|
ModelPrediction::new("A".to_owned(), 0.5, 0.8),
|
|
ModelPrediction::new("B".to_owned(), 0.3, 0.7),
|
|
];
|
|
let weights = HashMap::new();
|
|
|
|
let normalized = SignalAggregator::normalize_effective_weights(&predictions, &weights);
|
|
|
|
assert_eq!(normalized.len(), 2);
|
|
let sum: f64 = normalized.values().sum();
|
|
assert!(
|
|
(sum - 1.0).abs() < 1e-10,
|
|
"Uniform fallback weights must sum to 1.0, got {}",
|
|
sum
|
|
);
|
|
for (_, w) in &normalized {
|
|
assert!(
|
|
(*w - 0.5).abs() < 1e-10,
|
|
"Each weight should be 0.5 for 2 models, got {}",
|
|
w
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_effective_weights_single_model() {
|
|
let predictions = vec![ModelPrediction::new("SOLO".to_owned(), 0.9, 0.95)];
|
|
let mut weights = HashMap::new();
|
|
weights.insert(
|
|
"SOLO".to_owned(),
|
|
ModelWeight::new("SOLO".to_owned(), 0.7),
|
|
);
|
|
|
|
let normalized = SignalAggregator::normalize_effective_weights(&predictions, &weights);
|
|
|
|
assert_eq!(normalized.len(), 1);
|
|
let w = normalized.get("SOLO").copied().unwrap_or(0.0);
|
|
assert!(
|
|
(w - 1.0).abs() < 1e-10,
|
|
"Single model must get weight 1.0, got {}",
|
|
w
|
|
);
|
|
}
|
|
|
|
/// Verify DST-aware trading session boundaries.
|
|
///
|
|
/// June 15 2026 is during EDT (UTC-4), so 13:30 UTC = 09:30 ET (Regular).
|
|
/// The old hardcoded UTC-5 would have mapped 13:30 UTC to 08:30 ET (PreMarket) -- wrong.
|
|
#[test]
|
|
fn test_trading_session_dst_correctness() {
|
|
// --- EDT (summer): UTC-4 ---
|
|
// June 15 2026 13:30 UTC => 09:30 ET => Regular
|
|
let june_1330 = Utc.with_ymd_and_hms(2026, 6, 15, 13, 30, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(june_1330),
|
|
TradingSession::Regular,
|
|
"13:30 UTC in June (EDT) should be 09:30 ET = Regular, not PreMarket"
|
|
);
|
|
|
|
// June 15 2026 20:00 UTC => 16:00 ET => AfterHours
|
|
let june_2000 = Utc.with_ymd_and_hms(2026, 6, 15, 20, 0, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(june_2000),
|
|
TradingSession::AfterHours,
|
|
"20:00 UTC in June (EDT) should be 16:00 ET = AfterHours"
|
|
);
|
|
|
|
// June 15 2026 08:00 UTC => 04:00 ET => PreMarket
|
|
let june_0800 = Utc.with_ymd_and_hms(2026, 6, 15, 8, 0, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(june_0800),
|
|
TradingSession::PreMarket,
|
|
"08:00 UTC in June (EDT) should be 04:00 ET = PreMarket"
|
|
);
|
|
|
|
// --- EST (winter): UTC-5 ---
|
|
// January 15 2026 14:30 UTC => 09:30 ET => Regular
|
|
let jan_1430 = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(jan_1430),
|
|
TradingSession::Regular,
|
|
"14:30 UTC in January (EST) should be 09:30 ET = Regular"
|
|
);
|
|
|
|
// January 15 2026 21:00 UTC => 16:00 ET => AfterHours
|
|
let jan_2100 = Utc.with_ymd_and_hms(2026, 1, 15, 21, 0, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(jan_2100),
|
|
TradingSession::AfterHours,
|
|
"21:00 UTC in January (EST) should be 16:00 ET = AfterHours"
|
|
);
|
|
|
|
// January 15 2026 03:00 UTC => 22:00 ET (prev day) => AfterHours
|
|
let jan_0300 = Utc.with_ymd_and_hms(2026, 1, 15, 3, 0, 0).unwrap();
|
|
assert_eq!(
|
|
EnsembleCoordinator::trading_session_at(jan_0300),
|
|
TradingSession::AfterHours,
|
|
"03:00 UTC in January (EST) should be 22:00 ET = AfterHours"
|
|
);
|
|
}
|
|
}
|