diff --git a/services/broker_gateway_service/src/service.rs b/services/broker_gateway_service/src/service.rs index cb73f84fb..825cda7b9 100644 --- a/services/broker_gateway_service/src/service.rs +++ b/services/broker_gateway_service/src/service.rs @@ -94,6 +94,21 @@ impl BrokerGatewayService { } } +/// Convert a quantity in lots to cTrader volume (lots * 100,000). +/// +/// Returns an error if the result is not finite, negative, or exceeds i64 range. +/// Uses rounding to avoid silent truncation of fractional lots. +fn convert_quantity_to_volume(quantity: f64) -> Result { + let volume_f = quantity * 100_000.0; + if !volume_f.is_finite() || volume_f < 0.0 || volume_f > i64::MAX as f64 { + return Err(Status::invalid_argument(format!( + "Volume overflow: quantity {} produces volume {}", + quantity, volume_f + ))); + } + Ok(volume_f.round() as i64) +} + #[tonic::async_trait] impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayService { #[instrument(skip(self), fields(symbol, side, quantity))] @@ -217,7 +232,7 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic _ => ctrader_openapi::proto::ProtoOaOrderType::Market, }; - let volume = (req.quantity * 100_000.0) as i64; // lots → cTrader volume + let volume = convert_quantity_to_volume(req.quantity)?; let limit_price = req.price; let stop_price_val = req.stop_price; let comment = req.metadata.get("comment").cloned(); @@ -668,3 +683,61 @@ impl broker_gateway_service_server::BrokerGatewayService for BrokerGatewayServic })) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_quantity_normal() { + // 1.0 lot = 100,000 volume + assert_eq!(convert_quantity_to_volume(1.0).ok(), Some(100_000)); + // 0.01 lot (micro lot) = 1,000 volume + assert_eq!(convert_quantity_to_volume(0.01).ok(), Some(1_000)); + // 10.5 lots = 1,050,000 volume + assert_eq!(convert_quantity_to_volume(10.5).ok(), Some(1_050_000)); + } + + #[test] + fn test_convert_quantity_fractional_rounding() { + // 0.011 lots should round to 1,100 (not truncate to 1,099) + assert_eq!(convert_quantity_to_volume(0.011).ok(), Some(1_100)); + // 0.015 lots = 1,500.0 exactly + assert_eq!(convert_quantity_to_volume(0.015).ok(), Some(1_500)); + } + + #[test] + fn test_convert_quantity_overflow() { + // Huge value should overflow i64 + let result = convert_quantity_to_volume(f64::MAX); + assert!(result.is_err()); + let err = result.err().map(|s| format!("{}", s.message())); + assert!(err.as_deref().unwrap_or("").contains("Volume overflow")); + } + + #[test] + fn test_convert_quantity_negative() { + let result = convert_quantity_to_volume(-1.0); + assert!(result.is_err()); + } + + #[test] + fn test_convert_quantity_nan() { + let result = convert_quantity_to_volume(f64::NAN); + assert!(result.is_err()); + } + + #[test] + fn test_convert_quantity_infinity() { + let result = convert_quantity_to_volume(f64::INFINITY); + assert!(result.is_err()); + let result_neg = convert_quantity_to_volume(f64::NEG_INFINITY); + assert!(result_neg.is_err()); + } + + #[test] + fn test_convert_quantity_zero() { + // Zero is technically valid (non-negative, finite) + assert_eq!(convert_quantity_to_volume(0.0).ok(), Some(0)); + } +} diff --git a/services/trading_service/src/services/enhanced_ml.rs b/services/trading_service/src/services/enhanced_ml.rs index 7b15f9aff..c8c3867fe 100644 --- a/services/trading_service/src/services/enhanced_ml.rs +++ b/services/trading_service/src/services/enhanced_ml.rs @@ -1689,23 +1689,32 @@ struct RealTFTModel { impl RealTFTModel { /// Create new TFT model from checkpoint /// - /// NOTE: Checkpoint loading deferred to ml crate to avoid dependency issues - /// Trading service creates TFT with default config, marked as "trained" - /// Actual checkpoint loading will be handled by ml crate's TFT implementation + /// Validates that the checkpoint file exists before creating the model. + /// Returns an error if the checkpoint is missing -- never sets is_trained=true + /// on random weights. pub fn from_checkpoint( model_id: String, checkpoint_path: &std::path::Path, ) -> ml::MLResult { use ml::tft::{TFTConfig, TemporalFusionTransformer}; + // Validate checkpoint file exists before proceeding + if !checkpoint_path.exists() { + return Err(ml::MLError::CheckpointError(format!( + "TFT checkpoint not found: {}. Cannot load model with random weights.", + checkpoint_path.display() + ))); + } + info!( "Initializing TFT model (checkpoint: {})", checkpoint_path.display() ); // TFT configuration matching Wave 160 training + // input_dim must equal num_static + num_known + num_unknown = 5 + 10 + 16 = 31 let config = TFTConfig { - input_dim: 16, // From feature engineering + input_dim: 31, hidden_dim: 128, num_heads: 8, num_layers: 3, @@ -1726,16 +1735,20 @@ impl RealTFTModel { target_throughput_pps: 100_000, }; - // Create TFT model (checkpoint loading handled by ml crate) - let mut tft = TemporalFusionTransformer::new(config.clone()) + // Create TFT model -- checkpoint weight loading not yet implemented in ml crate, + // so we do NOT set is_trained=true. The model will refuse to produce predictions + // until real weight loading is added. + let tft = TemporalFusionTransformer::new(config.clone()) .map_err(|e| ml::MLError::ModelError(format!("Failed to create TFT: {}", e)))?; - // Mark as trained (production deployment assumes trained checkpoints) - tft.is_trained = true; - - info!( - "✅ Initialized TFT model {} (checkpoint loading deferred to ml crate)", - model_id + // NOTE: is_trained is NOT set to true. The checkpoint file exists but the ml crate + // does not yet support loading TFT weights from safetensors. Callers must check + // is_ready() before using this model. + warn!( + "TFT model {} created with architecture only -- safetensors weight loading not yet implemented in ml crate. \ + Checkpoint exists at {} but weights are random. is_trained=false.", + model_id, + checkpoint_path.display() ); Ok(Self { @@ -1757,12 +1770,20 @@ impl MLModel for RealTFTModel { } async fn predict(&self, features: &Features) -> ml::MLResult { + let tft = self.model.read().await; + + // Refuse to predict with untrained (random weight) model + if !tft.is_trained { + return Err(ml::MLError::InferenceError(format!( + "TFT model '{}' has not been trained -- refusing to predict with random weights", + self.model_id + ))); + } + // Simplified TFT prediction using feature aggregation // Full TFT multi-horizon prediction requires ndarray (in ml crate) // This wrapper provides basic signal for ensemble voting - let _tft = self.model.read().await; - // Simple prediction based on features (TFT multi-horizon deferred to ml crate) // In production, this would call tft.predict_fast() with proper tensor conversion let feature_sum: f64 = features.values.iter().sum(); @@ -1795,14 +1816,18 @@ impl MLModel for RealTFTModel { } fn is_ready(&self) -> bool { - true + // Only report ready if the model has actually been trained with real weights + self.model + .try_read() + .map(|m| m.is_trained) + .unwrap_or(false) } fn get_metadata(&self) -> ModelMetadata { ModelMetadata { model_type: ModelType::TFT, version: "1.0.0".to_string(), - features_used: 16, // num_unknown_features + features_used: self.config.input_dim, memory_usage_mb: 180.0, // Estimated for transformer architecture additional_metadata: std::collections::HashMap::new(), } @@ -1823,12 +1848,24 @@ struct RealMamba2Model { impl RealMamba2Model { /// Create new Mamba2 model from checkpoint + /// + /// Validates that the checkpoint file exists before creating the model. + /// Returns an error if the checkpoint is missing -- never sets is_trained=true + /// on random weights. pub fn from_checkpoint( model_id: String, checkpoint_path: &std::path::Path, ) -> ml::MLResult { use ml::mamba::{Mamba2Config, Mamba2SSM}; + // Validate checkpoint file exists before proceeding + if !checkpoint_path.exists() { + return Err(ml::MLError::CheckpointError(format!( + "Mamba2 checkpoint not found: {}. Cannot load model with random weights.", + checkpoint_path.display() + ))); + } + info!( "Initializing Mamba2 model (checkpoint: {})", checkpoint_path.display() @@ -1838,15 +1875,19 @@ impl RealMamba2Model { use ml::prelude::Device; let device = Device::Cpu; - let mut mamba2 = Mamba2SSM::new(config.clone(), &device) + // Create Mamba2 model -- checkpoint weight loading not yet implemented, + // so we do NOT set is_trained=true. + let mamba2 = Mamba2SSM::new(config.clone(), &device) .map_err(|e| ml::MLError::ModelError(format!("Failed to create Mamba2: {}", e)))?; - // Mark as trained (production deployment assumes trained checkpoints) - mamba2.is_trained = true; - - info!( - "Initialized Mamba2 model {} (d_model={}, layers={})", - model_id, config.d_model, config.num_layers + // NOTE: is_trained is NOT set to true. The checkpoint file exists but the ml crate + // does not yet support loading Mamba2 weights from safetensors. Callers must check + // is_ready() before using this model. + warn!( + "Mamba2 model {} created with architecture only -- safetensors weight loading not yet implemented. \ + Checkpoint exists at {} but weights are random. is_trained=false.", + model_id, + checkpoint_path.display() ); Ok(Self { @@ -1870,20 +1911,40 @@ impl MLModel for RealMamba2Model { async fn predict(&self, features: &Features) -> ml::MLResult { let mut mamba2 = self.model.write().await; + // Refuse to predict with untrained (random weight) model + if !mamba2.is_trained { + return Err(ml::MLError::InferenceError(format!( + "Mamba2 model '{}' has not been trained -- refusing to predict with random weights", + self.model_id + ))); + } + // Pad or truncate feature vector to match d_model let d_model = mamba2.config.d_model; let mut input = vec![0.0f64; d_model]; let copy_len = features.values.len().min(d_model); input[..copy_len].copy_from_slice(&features.values[..copy_len]); - // Use fast single-sample prediction + // Use fast single-sample prediction -- propagate errors as InferenceError + // so the fallback manager can degrade model health let raw_prediction = mamba2 .predict_single_fast(&input) .map_err(|e| { warn!(model_id = %self.model_id, error = %e, "Mamba2 inference failed"); - e + ml::MLError::InferenceError(format!( + "Mamba2 model '{}' inference failed: {}", + self.model_id, e + )) })?; + // Validate prediction is finite + if !raw_prediction.is_finite() { + return Err(ml::MLError::InferenceError(format!( + "Mamba2 model '{}' produced non-finite prediction: {}", + self.model_id, raw_prediction + ))); + } + // Normalize to 0-1 range using sigmoid let prediction_value = (1.0 / (1.0 + (-raw_prediction).exp())).clamp(0.0, 1.0); @@ -1904,7 +1965,12 @@ impl MLModel for RealMamba2Model { } fn is_ready(&self) -> bool { - true + // Only report ready if the model has actually been trained with real weights + // This is checked synchronously so we use try_read + self.model + .try_read() + .map(|m| m.is_trained) + .unwrap_or(false) } fn get_metadata(&self) -> ModelMetadata { @@ -2627,4 +2693,190 @@ mod enhanced_ml_tests { "Error message should indicate the feature is not connected" ); } + + // ----------------------------------------------------------------------- + // 13. TFT from_checkpoint fails on nonexistent path (C5 fix) + // ----------------------------------------------------------------------- + #[test] + fn test_tft_from_checkpoint_fails_on_nonexistent_path() { + let fake_path = std::path::Path::new("/tmp/nonexistent_tft_checkpoint_12345.safetensors"); + let result = RealTFTModel::from_checkpoint("tft-test".to_string(), fake_path); + assert!(result.is_err(), "from_checkpoint must fail when checkpoint file does not exist"); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("not found"), + "Error message should mention 'not found', got: {}", + err_msg + ); + } + + // ----------------------------------------------------------------------- + // 14. Mamba2 from_checkpoint fails on nonexistent path (C5 fix) + // ----------------------------------------------------------------------- + #[test] + fn test_mamba2_from_checkpoint_fails_on_nonexistent_path() { + let fake_path = std::path::Path::new("/tmp/nonexistent_mamba2_checkpoint_12345.safetensors"); + let result = RealMamba2Model::from_checkpoint("mamba2-test".to_string(), fake_path); + assert!(result.is_err(), "from_checkpoint must fail when checkpoint file does not exist"); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("not found"), + "Error message should mention 'not found', got: {}", + err_msg + ); + } + + // ----------------------------------------------------------------------- + // 15. TFT from_checkpoint does NOT set is_trained on random weights (C5 fix) + // ----------------------------------------------------------------------- + #[test] + fn test_tft_from_checkpoint_does_not_mark_trained_with_random_weights() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_tft_checkpoint_c5.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let result = RealTFTModel::from_checkpoint("tft-test".to_string(), &checkpoint_path); + // Clean up + let _ = std::fs::remove_file(&checkpoint_path); + + // The model should be created successfully... + let model = result.expect("TFT model creation should succeed when checkpoint exists"); + + // ...but is_trained must NOT be true (weights are random, not loaded from checkpoint) + let tft = model.model.try_read().unwrap(); + assert!( + !tft.is_trained, + "TFT model must NOT be marked as trained when using random weights" + ); + } + + // ----------------------------------------------------------------------- + // 16. Mamba2 from_checkpoint does NOT set is_trained on random weights (C5 fix) + // ----------------------------------------------------------------------- + #[test] + fn test_mamba2_from_checkpoint_does_not_mark_trained_with_random_weights() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_mamba2_checkpoint_c5.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let result = RealMamba2Model::from_checkpoint("mamba2-test".to_string(), &checkpoint_path); + // Clean up + let _ = std::fs::remove_file(&checkpoint_path); + + // The model should be created successfully... + let model = result.expect("Mamba2 model creation should succeed when checkpoint exists"); + + // ...but is_trained must NOT be true (weights are random, not loaded from checkpoint) + let mamba2 = model.model.try_read().unwrap(); + assert!( + !mamba2.is_trained, + "Mamba2 model must NOT be marked as trained when using random weights" + ); + } + + // ----------------------------------------------------------------------- + // 17. Mamba2 predict refuses untrained model (M3 fix) + // ----------------------------------------------------------------------- + #[tokio::test] + async fn test_mamba2_predict_refuses_untrained_model() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_mamba2_predict_m3.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let model = RealMamba2Model::from_checkpoint("mamba2-test".to_string(), &checkpoint_path) + .expect("Mamba2 model creation should succeed"); + let _ = std::fs::remove_file(&checkpoint_path); + + // Model is untrained, predict should fail with InferenceError + let features = ml::Features { + values: vec![0.1, 0.2, 0.3], + names: vec!["a".to_string(), "b".to_string(), "c".to_string()], + timestamp: 0, + symbol: None, + }; + + let result = model.predict(&features).await; + assert!(result.is_err(), "predict must fail on untrained Mamba2 model"); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("not been trained"), + "Error should mention model is not trained, got: {}", + err_msg + ); + } + + // ----------------------------------------------------------------------- + // 18. Mamba2 is_ready returns false for untrained model (M3 fix) + // ----------------------------------------------------------------------- + #[test] + fn test_mamba2_is_ready_false_when_untrained() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_mamba2_ready_m3.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let model = RealMamba2Model::from_checkpoint("mamba2-test".to_string(), &checkpoint_path) + .expect("Mamba2 model creation should succeed"); + let _ = std::fs::remove_file(&checkpoint_path); + + assert!( + !model.is_ready(), + "Mamba2 model must report is_ready=false when weights are random" + ); + } + + // ----------------------------------------------------------------------- + // 19. TFT predict refuses untrained model (C5/M3 fix) + // ----------------------------------------------------------------------- + #[tokio::test] + async fn test_tft_predict_refuses_untrained_model() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_tft_predict_c5.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let model = RealTFTModel::from_checkpoint("tft-test".to_string(), &checkpoint_path) + .expect("TFT model creation should succeed"); + let _ = std::fs::remove_file(&checkpoint_path); + + // Model is untrained, predict should fail with InferenceError + let features = ml::Features { + values: vec![0.1, 0.2, 0.3], + names: vec!["a".to_string(), "b".to_string(), "c".to_string()], + timestamp: 0, + symbol: None, + }; + + let result = model.predict(&features).await; + assert!(result.is_err(), "predict must fail on untrained TFT model"); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("not been trained"), + "Error should mention model is not trained, got: {}", + err_msg + ); + } + + // ----------------------------------------------------------------------- + // 20. TFT is_ready returns false for untrained model + // ----------------------------------------------------------------------- + #[test] + fn test_tft_is_ready_false_when_untrained() { + // Create a real temp file so the existence check passes + let tmp_dir = std::env::temp_dir(); + let checkpoint_path = tmp_dir.join("test_tft_ready_c5.safetensors"); + std::fs::write(&checkpoint_path, b"fake checkpoint data").unwrap(); + + let model = RealTFTModel::from_checkpoint("tft-test".to_string(), &checkpoint_path) + .expect("TFT model creation should succeed"); + let _ = std::fs::remove_file(&checkpoint_path); + + assert!( + !model.is_ready(), + "TFT model must report is_ready=false when weights are random" + ); + } } diff --git a/web-gateway/src/routes/auth.rs b/web-gateway/src/routes/auth.rs index 9f0523d29..dd5fd8488 100644 --- a/web-gateway/src/routes/auth.rs +++ b/web-gateway/src/routes/auth.rs @@ -1,6 +1,7 @@ use axum::{extract::State, routing, Json, Router}; use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; +use tracing::warn; use crate::auth::claims::Claims; use crate::error::AppError; @@ -23,11 +24,24 @@ pub fn router() -> Router { // SECURITY: This is a development-only login stub. Before production deployment, // replace with real authentication (LDAP, OAuth2, bcrypt-verified credentials, etc.). -// Currently accepts any non-empty username/password combination. +// Gated behind FOXHUNT_DEV_AUTH=true — disabled by default. async fn login( State(state): State, Json(body): Json, ) -> Result, AppError> { + // Gate: dev auth stub must be explicitly enabled + let dev_auth = std::env::var("FOXHUNT_DEV_AUTH").unwrap_or_default(); + if dev_auth != "true" { + return Err(AppError::Internal(anyhow::anyhow!( + "Production auth provider not configured. Set FOXHUNT_DEV_AUTH=true for development stub." + ))); + } + + warn!( + "DEV AUTH STUB: Accepting login for '{}' without password verification.", + body.username + ); + if body.username.is_empty() || body.password.is_empty() { return Err(AppError::BadRequest("Username and password required".into())); } @@ -87,8 +101,14 @@ mod tests { .with_state(state) } + /// Enable the dev auth stub for tests that need it + fn enable_dev_auth() { + unsafe { std::env::set_var("FOXHUNT_DEV_AUTH", "true") }; + } + #[tokio::test] async fn test_login_returns_token() { + enable_dev_auth(); let app = test_app("test-secret"); let body = serde_json::json!({"username": "admin", "password": "pass123"}); @@ -109,6 +129,7 @@ mod tests { #[tokio::test] async fn test_login_empty_username_returns_400() { + enable_dev_auth(); let app = test_app("test-secret"); let body = serde_json::json!({"username": "", "password": "pass123"}); @@ -125,6 +146,7 @@ mod tests { #[tokio::test] async fn test_login_no_jwt_secret_returns_500() { + enable_dev_auth(); let app = test_app(""); let body = serde_json::json!({"username": "admin", "password": "pass123"}); @@ -138,4 +160,9 @@ mod tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } + + // Note: negative test for FOXHUNT_DEV_AUTH=false is not included because + // env vars are process-global and tests run in parallel, causing races. + // The safety guarantee is enforced by the handler checking the env var at + // runtime — without FOXHUNT_DEV_AUTH=true, login returns 500. }