Reduce log noise for non-critical operational paths: connection retries, expected fallbacks, graceful degradation, and optional feature absence. Keeps warn/error for genuine failures requiring attention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1518 lines
58 KiB
Rust
1518 lines
58 KiB
Rust
//! Service state management and business logic coordination
|
|
//!
|
|
//! This module provides clean repository-based dependency injection,
|
|
//! eliminating direct database coupling from business logic.
|
|
|
|
use crate::error::{TradingServiceError, TradingServiceResult};
|
|
use crate::event_persistence::EventPersistence;
|
|
use crate::event_streaming::publisher::EventPublisher;
|
|
use crate::proto::monitoring::SystemMetrics;
|
|
use crate::repositories::*;
|
|
use crate::repository_impls::PostgresConfigRepository;
|
|
use trading_engine::trading::account_manager::AccountManager;
|
|
use trading_engine::trading::order_manager::OrderManager;
|
|
use trading_engine::trading::position_manager::PositionManager;
|
|
|
|
// Import provider traits for data connection methods
|
|
use data::providers::{MarketDataProvider, RealTimeProvider};
|
|
|
|
use futures::StreamExt;
|
|
use std::sync::Arc;
|
|
use tokio::sync::broadcast;
|
|
use tokio::sync::RwLock;
|
|
|
|
/// Central state manager for the trading service with repository pattern
|
|
///
|
|
/// This struct coordinates all business logic using repository dependency injection:
|
|
/// - Trading operations through TradingRepository
|
|
///
|
|
/// - Market data access through MarketDataRepository
|
|
/// - Risk management through RiskRepository
|
|
///
|
|
/// - Configuration through ConfigRepository
|
|
/// - NO DIRECT DATABASE COUPLING
|
|
#[derive(Clone)]
|
|
pub struct TradingServiceState {
|
|
/// Trading repository for orders, executions, positions
|
|
pub trading_repository: Arc<dyn TradingRepository>,
|
|
|
|
/// Market data repository for prices, order books
|
|
pub market_data_repository: Arc<dyn MarketDataRepository>,
|
|
|
|
/// Risk repository for limits, calculations, alerts
|
|
pub risk_repository: Arc<dyn RiskRepository>,
|
|
|
|
/// Configuration repository for settings and secrets
|
|
pub config_repository: Arc<crate::repository_impls::PostgresConfigRepository>,
|
|
|
|
/// Database connection pool for direct SQL queries
|
|
pub db_pool: sqlx::PgPool,
|
|
|
|
/// Risk management engine (business logic only)
|
|
pub risk_engine: Arc<RwLock<RiskEngine>>,
|
|
|
|
/// ML model registry and predictor (business logic only)
|
|
pub ml_engine: Arc<RwLock<MLEngine>>,
|
|
|
|
/// Market data manager (business logic only)
|
|
pub market_data: Arc<RwLock<MarketDataManager>>,
|
|
|
|
/// `Order` management system (business logic only)
|
|
pub order_manager: Arc<RwLock<OrderManager>>,
|
|
|
|
/// `Position` tracking (business logic only)
|
|
pub position_manager: Arc<RwLock<PositionManager>>,
|
|
|
|
/// Account management (business logic only)
|
|
pub account_manager: Arc<RwLock<AccountManager>>,
|
|
|
|
/// Event publisher for real-time streaming
|
|
pub event_publisher: Arc<EventPublisher>,
|
|
|
|
/// Event persistence for compliance and audit trail
|
|
pub event_persistence: Arc<EventPersistence>,
|
|
|
|
/// System metrics and monitoring
|
|
pub metrics: Arc<RwLock<SystemMetrics>>,
|
|
|
|
/// Kill switch system for emergency shutdown
|
|
pub kill_switch_system: Option<Arc<crate::kill_switch_integration::TradingServiceKillSwitch>>,
|
|
|
|
/// Ensemble coordinator for ML predictions (DQN, PPO, TFT)
|
|
pub ensemble_coordinator: Option<Arc<crate::ensemble_coordinator::EnsembleCoordinator>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for TradingServiceState {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("TradingServiceState")
|
|
.field("trading_repository", &"<dyn TradingRepository>")
|
|
.field("market_data_repository", &"<dyn MarketDataRepository>")
|
|
.field("risk_repository", &"<dyn RiskRepository>")
|
|
.field("config_repository", &self.config_repository)
|
|
.field("db_pool", &"<PgPool>")
|
|
.field("risk_engine", &self.risk_engine)
|
|
.field("ml_engine", &self.ml_engine)
|
|
.field("market_data", &self.market_data)
|
|
.field("order_manager", &self.order_manager)
|
|
.field("position_manager", &self.position_manager)
|
|
.field("account_manager", &self.account_manager)
|
|
.field("event_publisher", &self.event_publisher)
|
|
.field("event_persistence", &self.event_persistence)
|
|
.field("metrics", &self.metrics)
|
|
.field("kill_switch_system", &self.kill_switch_system)
|
|
.field("ensemble_coordinator", &self.ensemble_coordinator)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl TradingServiceState {
|
|
/// Create new trading service state with repository dependency injection
|
|
pub async fn new_with_repositories(
|
|
trading_repository: Arc<dyn TradingRepository>,
|
|
market_data_repository: Arc<dyn MarketDataRepository>,
|
|
risk_repository: Arc<dyn RiskRepository>,
|
|
config_repository: Arc<PostgresConfigRepository>,
|
|
db_pool: sqlx::PgPool,
|
|
event_persistence: Arc<EventPersistence>,
|
|
kill_switch_system: Option<Arc<crate::kill_switch_integration::TradingServiceKillSwitch>>,
|
|
ensemble_coordinator: Option<Arc<crate::ensemble_coordinator::EnsembleCoordinator>>,
|
|
) -> TradingServiceResult<Self> {
|
|
// Initialize business logic components (no database coupling)
|
|
let risk_engine = Arc::new(RwLock::new(RiskEngine::new()));
|
|
let ml_engine = Arc::new(RwLock::new(MLEngine::new()));
|
|
let market_data = Arc::new(RwLock::new(MarketDataManager::new()));
|
|
let order_manager = Arc::new(RwLock::new(OrderManager::new()));
|
|
let position_manager = Arc::new(RwLock::new(PositionManager::new()));
|
|
let account_manager = Arc::new(RwLock::new(AccountManager::new()));
|
|
// Create broadcast channel for event publishing
|
|
let (event_sender, _) = broadcast::channel(10000);
|
|
let event_publisher = Arc::new(EventPublisher::new(event_sender));
|
|
let metrics = Arc::new(RwLock::new(SystemMetrics::default()));
|
|
|
|
Ok(Self {
|
|
trading_repository,
|
|
market_data_repository,
|
|
risk_repository,
|
|
config_repository,
|
|
db_pool,
|
|
event_persistence,
|
|
risk_engine,
|
|
ml_engine,
|
|
market_data,
|
|
order_manager,
|
|
position_manager,
|
|
account_manager,
|
|
event_publisher,
|
|
metrics,
|
|
kill_switch_system,
|
|
ensemble_coordinator,
|
|
})
|
|
}
|
|
|
|
/// Initialize service state with repository-based configuration
|
|
pub async fn initialize(&self) -> TradingServiceResult<()> {
|
|
// Initialize risk engine with repository-based configuration
|
|
let mut risk_engine = self.risk_engine.write().await;
|
|
risk_engine
|
|
.initialize_with_config_repository(&self.config_repository)
|
|
.await?;
|
|
|
|
// Initialize ML engine with repository-based configuration
|
|
let mut ml_engine = self.ml_engine.write().await;
|
|
ml_engine
|
|
.initialize_with_config_repository(&self.config_repository)
|
|
.await?;
|
|
|
|
// Initialize market data connections with repository-based configuration
|
|
let mut market_data = self.market_data.write().await;
|
|
market_data
|
|
.initialize_with_config_repository(&self.config_repository)
|
|
.await?;
|
|
|
|
// Start event processing for market data providers
|
|
market_data.start_event_processing().await?;
|
|
|
|
Ok(())
|
|
}
|
|
/// Create new trading service state for testing purposes
|
|
///
|
|
/// This creates a minimal state with mock repositories suitable for integration tests.
|
|
///
|
|
/// Note: This function is only available when building tests.
|
|
pub async fn new_for_testing() -> TradingServiceResult<Self> {
|
|
// For testing, create a minimal repository setup using PostgreSQL implementations
|
|
use crate::event_persistence::EventPersistence;
|
|
use crate::repository_impls::{
|
|
PostgresConfigRepository, PostgresMarketDataRepository, PostgresRiskRepository,
|
|
PostgresTradingRepository,
|
|
};
|
|
use sqlx::PgPool;
|
|
|
|
// Get database URL for testing
|
|
let db_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
let pool = PgPool::connect(&db_url).await.map_err(|e| {
|
|
crate::error::TradingServiceError::Internal {
|
|
message: format!("Failed to create test database pool: {}", e),
|
|
}
|
|
})?;
|
|
|
|
// Create PostgreSQL repositories for testing
|
|
let trading_repository =
|
|
Arc::new(PostgresTradingRepository::new(pool.clone())) as Arc<dyn TradingRepository>;
|
|
let market_data_repository = Arc::new(PostgresMarketDataRepository::new(pool.clone()))
|
|
as Arc<dyn MarketDataRepository>;
|
|
let risk_repository =
|
|
Arc::new(PostgresRiskRepository::new(pool.clone())) as Arc<dyn RiskRepository>;
|
|
|
|
// Create config repository using the same pool
|
|
let config_repository = Arc::new(PostgresConfigRepository::new(pool.clone()));
|
|
|
|
// Create event persistence with a test directory
|
|
let event_persistence =
|
|
Arc::new(EventPersistence::new_for_testing().await.map_err(|e| {
|
|
crate::error::TradingServiceError::Internal {
|
|
message: format!("Failed to create event persistence: {}", e),
|
|
}
|
|
})?);
|
|
|
|
// Create state without kill switch, model cache, or ensemble for simplicity
|
|
Self::new_with_repositories(
|
|
trading_repository,
|
|
market_data_repository,
|
|
risk_repository,
|
|
config_repository,
|
|
pool,
|
|
event_persistence,
|
|
None, // kill_switch_system
|
|
None, // ensemble_coordinator
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Get health status of all components
|
|
pub async fn get_health_status(&self) -> HealthStatus {
|
|
// Check all components and return overall health
|
|
let market_data_health = self.market_data.read().await.get_provider_health().await;
|
|
|
|
// Check if any providers are unhealthy
|
|
let has_unhealthy_providers = market_data_health
|
|
.iter()
|
|
.any(|(_, status)| !status.connected);
|
|
|
|
// Check ensemble coordinator health
|
|
let ensemble_health = self.check_ensemble_health().await;
|
|
|
|
if has_unhealthy_providers {
|
|
HealthStatus::Degraded
|
|
} else if market_data_health.is_empty() {
|
|
HealthStatus::Critical // No providers available
|
|
} else if matches!(ensemble_health, EnsembleHealth::Unhealthy) {
|
|
HealthStatus::Degraded // Ensemble issues but trading can continue
|
|
} else {
|
|
HealthStatus::Healthy
|
|
}
|
|
}
|
|
|
|
/// Check ensemble coordinator health
|
|
async fn check_ensemble_health(&self) -> EnsembleHealth {
|
|
use tracing::{error, info};
|
|
|
|
let ensemble = match &self.ensemble_coordinator {
|
|
Some(coordinator) => coordinator,
|
|
None => {
|
|
info!("Ensemble coordinator not configured");
|
|
return EnsembleHealth::NotConfigured;
|
|
},
|
|
};
|
|
|
|
// Check if models are loaded
|
|
let model_count = ensemble.model_count().await;
|
|
if model_count == 0 {
|
|
error!("Ensemble coordinator has no models loaded");
|
|
return EnsembleHealth::Unhealthy;
|
|
}
|
|
|
|
// Mock health check - in production, this would:
|
|
// 1. Verify all 6 models are loaded (DQN, PPO, TFT x 2 each)
|
|
// 2. Check inference latency (<50μs P99)
|
|
// 3. Check model staleness (last updated)
|
|
// 4. Verify checkpoint integrity
|
|
|
|
info!("Ensemble coordinator healthy with {} models", model_count);
|
|
EnsembleHealth::Healthy
|
|
}
|
|
|
|
/// Get detailed ensemble health report
|
|
pub async fn get_ensemble_health_report(&self) -> EnsembleHealthReport {
|
|
let ensemble = match &self.ensemble_coordinator {
|
|
Some(coordinator) => coordinator,
|
|
None => {
|
|
return EnsembleHealthReport {
|
|
status: EnsembleHealth::NotConfigured,
|
|
models_loaded: 0,
|
|
expected_models: 6, // DQN, PPO, TFT (2 checkpoints each)
|
|
inference_latency_us: None,
|
|
last_prediction: None,
|
|
model_details: vec![],
|
|
};
|
|
},
|
|
};
|
|
|
|
let model_count = ensemble.model_count().await;
|
|
let status = if model_count == 0 {
|
|
EnsembleHealth::Unhealthy
|
|
} else if model_count < 6 {
|
|
EnsembleHealth::Degraded
|
|
} else {
|
|
EnsembleHealth::Healthy
|
|
};
|
|
|
|
EnsembleHealthReport {
|
|
status,
|
|
models_loaded: model_count,
|
|
expected_models: 6,
|
|
inference_latency_us: Some(42.0), // Mock latency
|
|
last_prediction: Some(std::time::SystemTime::now()),
|
|
model_details: vec![
|
|
ModelHealthDetail {
|
|
model_id: "DQN".to_string(),
|
|
checkpoint: "epoch_30".to_string(),
|
|
loaded: true,
|
|
last_inference: std::time::SystemTime::now(),
|
|
},
|
|
ModelHealthDetail {
|
|
model_id: "PPO".to_string(),
|
|
checkpoint: "epoch_30".to_string(),
|
|
loaded: true,
|
|
last_inference: std::time::SystemTime::now(),
|
|
},
|
|
ModelHealthDetail {
|
|
model_id: "TFT".to_string(),
|
|
checkpoint: "epoch_30".to_string(),
|
|
loaded: true,
|
|
last_inference: std::time::SystemTime::now(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Subscribe to market data for trading symbols
|
|
pub async fn subscribe_to_market_data(
|
|
&self,
|
|
symbols: Vec<common::types::Symbol>,
|
|
) -> TradingServiceResult<()> {
|
|
let mut market_data = self.market_data.write().await;
|
|
market_data.subscribe_to_symbols(symbols).await
|
|
}
|
|
|
|
/// Get market data event stream
|
|
pub async fn get_market_data_stream(
|
|
&self,
|
|
) -> tokio::sync::broadcast::Receiver<trading_engine::trading::data_interface::MarketDataEvent>
|
|
{
|
|
let market_data = self.market_data.read().await;
|
|
market_data.get_event_receiver()
|
|
}
|
|
|
|
/// Get ensemble coordinator reference (if initialized)
|
|
pub fn ensemble_coordinator(
|
|
&self,
|
|
) -> Option<&Arc<crate::ensemble_coordinator::EnsembleCoordinator>> {
|
|
self.ensemble_coordinator.as_ref()
|
|
}
|
|
|
|
/// Get trading signal from ensemble coordinator
|
|
///
|
|
/// This method orchestrates the full prediction flow:
|
|
/// 1. Extract features from market data
|
|
/// 2. Call ensemble coordinator for prediction
|
|
/// 3. Convert ensemble decision to trading signal
|
|
/// 4. Apply risk adjustments (position sizing)
|
|
/// 5. Return signal with ensemble attribution
|
|
///
|
|
/// Returns `Err(MLModel)` if no real prediction is available (ensemble not
|
|
/// initialised, feature extraction fails, or inference fails).
|
|
pub async fn get_ensemble_trading_signal(
|
|
&self,
|
|
symbol: &str,
|
|
) -> TradingServiceResult<EnsembleTradingSignal> {
|
|
use tracing::{info, warn};
|
|
|
|
// Check if ensemble coordinator is available
|
|
let ensemble = match &self.ensemble_coordinator {
|
|
Some(coordinator) => coordinator,
|
|
None => {
|
|
info!("Ensemble coordinator not initialized, using fallback strategy");
|
|
return self.get_fallback_trading_signal(symbol).await;
|
|
},
|
|
};
|
|
|
|
// Extract features from market data
|
|
let features = match self.extract_features_for_symbol(symbol).await {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
warn!(
|
|
"Feature extraction failed for {}: {}, using fallback",
|
|
symbol, e
|
|
);
|
|
return self.get_fallback_trading_signal(symbol).await;
|
|
},
|
|
};
|
|
|
|
// Get ensemble prediction
|
|
let ensemble_decision = match ensemble.predict(&features).await {
|
|
Ok(decision) => decision,
|
|
Err(e) => {
|
|
warn!(
|
|
"Ensemble prediction failed for {}: {}, using fallback",
|
|
symbol, e
|
|
);
|
|
return self.get_fallback_trading_signal(symbol).await;
|
|
},
|
|
};
|
|
|
|
// Convert ensemble decision to trading signal
|
|
let action = match ensemble_decision.action {
|
|
ml::ensemble::TradingAction::Buy => TradingActionType::Buy,
|
|
ml::ensemble::TradingAction::Sell => TradingActionType::Sell,
|
|
ml::ensemble::TradingAction::Hold => TradingActionType::Hold,
|
|
};
|
|
|
|
// Apply risk-based position sizing
|
|
let position_size = self
|
|
.calculate_position_size(
|
|
symbol,
|
|
ensemble_decision.confidence,
|
|
ensemble_decision.disagreement_rate,
|
|
)
|
|
.await?;
|
|
|
|
info!(
|
|
"Ensemble signal for {}: action={:?}, confidence={:.3}, size={}",
|
|
symbol, action, ensemble_decision.confidence, position_size
|
|
);
|
|
|
|
Ok(EnsembleTradingSignal {
|
|
symbol: symbol.to_string(),
|
|
action,
|
|
confidence: ensemble_decision.confidence,
|
|
position_size,
|
|
disagreement_rate: ensemble_decision.disagreement_rate,
|
|
model_votes: ensemble_decision.model_votes.clone(),
|
|
timestamp: std::time::SystemTime::now(),
|
|
})
|
|
}
|
|
|
|
/// Extract features from market data for a symbol.
|
|
///
|
|
/// Attempts to build a 51-dimensional feature vector from recent market ticks
|
|
/// retrieved via `market_data_repository.get_latest_prices`. When real data is
|
|
/// available the function derives price-based statistics (mean, std-dev, momentum,
|
|
/// volume average). All remaining dimensions are padded with zeros so the vector
|
|
/// is always exactly 51 elements — matching the DQN/PPO input dimension used
|
|
/// throughout this codebase.
|
|
///
|
|
/// On any failure (network error, insufficient history, etc.) the function logs a
|
|
/// `WARN`-level message and returns a zero-filled 51-dim vector rather than
|
|
/// propagating an error, so the ensemble coordinator can still run with neutral
|
|
/// inputs instead of crashing the prediction path.
|
|
async fn extract_features_for_symbol(
|
|
&self,
|
|
symbol: &str,
|
|
) -> TradingServiceResult<ml::Features> {
|
|
// ROADMAP: Replace tick-based approximation with full OHLCV bar pipeline
|
|
// -----------------------------------------------------------------------
|
|
// Current state: builds a 51-dim feature vector from the latest tick prices,
|
|
// which only provides point-in-time price data without proper OHLCV bars or
|
|
// technical indicators computed over bar windows.
|
|
//
|
|
// To upgrade:
|
|
// 1. Add `get_ohlcv_bars(symbol, timeframe, count)` to MarketDataRepository
|
|
// that returns Vec<OhlcvBar> from the market data store (TimescaleDB or
|
|
// in-memory ring buffer fed by the BarAggregator).
|
|
// 2. Compute the 21 technical indicators (SMA, EMA, RSI, MACD, BB, ATR, etc.)
|
|
// over the bar series using the existing indicator library in `ml/src/features/`.
|
|
// 3. Append 25 microstructure features (spread, depth imbalance, VWAP deviation,
|
|
// trade flow toxicity) from the order book snapshots.
|
|
// 4. Cache the computed 51-dim vector in a per-symbol DashMap so repeated
|
|
// predictions within the same bar window reuse the cached result.
|
|
// 5. Wire the cached features into EnsembleCoordinator::generate_and_save_prediction
|
|
// so it accepts an optional Features parameter (see trading.rs roadmap).
|
|
const FEATURE_DIM: usize = 51;
|
|
|
|
let zero_features = || {
|
|
let names: Vec<String> = (0..FEATURE_DIM).map(|i| format!("f{i}")).collect();
|
|
ml::Features::new(vec![0.0_f64; FEATURE_DIM], names).with_symbol(symbol.to_string())
|
|
};
|
|
|
|
// Attempt to retrieve the latest ticks for this symbol.
|
|
let ticks = match self
|
|
.market_data_repository
|
|
.get_latest_prices(&[symbol.to_string()])
|
|
.await
|
|
{
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::info!(
|
|
"FEATURE EXTRACTION: no market ticks yet for {}: {} — \
|
|
using zero-filled features",
|
|
symbol,
|
|
e
|
|
);
|
|
return Ok(zero_features());
|
|
},
|
|
};
|
|
|
|
if ticks.is_empty() {
|
|
tracing::info!(
|
|
"FEATURE EXTRACTION: no market ticks available for {} — \
|
|
using zero-filled features",
|
|
symbol
|
|
);
|
|
return Ok(zero_features());
|
|
}
|
|
|
|
// Derive basic price statistics from available ticks.
|
|
let prices: Vec<f64> = ticks.iter().map(|t| t.price).collect();
|
|
let volumes: Vec<f64> = ticks.iter().map(|t| t.quantity).collect();
|
|
let n = prices.len() as f64;
|
|
|
|
let price_mean = prices.iter().sum::<f64>() / n;
|
|
let price_var = prices.iter().map(|p| (p - price_mean).powi(2)).sum::<f64>() / n;
|
|
let price_std = price_var.sqrt();
|
|
|
|
let last_price = prices.last().copied().unwrap_or(price_mean);
|
|
let first_price = prices.first().copied().unwrap_or(price_mean);
|
|
// Momentum: return over the available window, clipped to [-1, 1].
|
|
let momentum = if first_price > 0.0 {
|
|
((last_price - first_price) / first_price).clamp(-1.0, 1.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let vol_mean = volumes.iter().sum::<f64>() / n;
|
|
// Normalised std-dev (coefficient of variation), clamped for stability.
|
|
let price_cv = if price_mean > 0.0 {
|
|
(price_std / price_mean).clamp(0.0, 1.0)
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Build the feature vector. The first 6 slots hold real data; the rest are
|
|
// zeros until a full indicator pipeline is wired in.
|
|
let mut values = vec![0.0_f64; FEATURE_DIM];
|
|
values[0] = price_mean;
|
|
values[1] = price_std;
|
|
values[2] = price_cv;
|
|
values[3] = momentum;
|
|
values[4] = vol_mean;
|
|
values[5] = n / 1000.0; // tick count, normalised
|
|
|
|
let names: Vec<String> = [
|
|
"price_mean",
|
|
"price_std",
|
|
"price_cv",
|
|
"momentum",
|
|
"vol_mean",
|
|
"tick_count_norm",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.chain((6..FEATURE_DIM).map(|i| format!("f{i}")))
|
|
.collect();
|
|
|
|
tracing::debug!(
|
|
"FEATURE EXTRACTION: {} ticks → price_mean={:.4} momentum={:.4} for {}",
|
|
ticks.len(),
|
|
price_mean,
|
|
momentum,
|
|
symbol
|
|
);
|
|
|
|
Ok(ml::Features::new(values, names).with_symbol(symbol.to_string()))
|
|
}
|
|
|
|
/// Calculate position size based on ensemble confidence and disagreement
|
|
async fn calculate_position_size(
|
|
&self,
|
|
_symbol: &str,
|
|
confidence: f64,
|
|
disagreement_rate: f64,
|
|
) -> TradingServiceResult<u64> {
|
|
// Base position size (in shares/contracts)
|
|
let base_size: u64 = 100;
|
|
|
|
// Scale by confidence (0.5-1.0 confidence → 0-1.0 multiplier)
|
|
let confidence_multiplier = ((confidence - 0.5) * 2.0).max(0.0).min(1.0);
|
|
|
|
// Reduce size based on disagreement (high disagreement = lower size)
|
|
let disagreement_penalty = 1.0 - disagreement_rate;
|
|
|
|
// Final position size
|
|
let position_size =
|
|
(base_size as f64 * confidence_multiplier * disagreement_penalty) as u64;
|
|
|
|
Ok(position_size.max(10)) // Minimum 10 shares/contracts
|
|
}
|
|
|
|
/// Return an error when no ML prediction is available.
|
|
///
|
|
/// Previously this returned a hardcoded Hold signal at 0.60 confidence,
|
|
/// which looked like a legitimate medium-confidence prediction and could
|
|
/// trigger trades. Now it returns an explicit error so callers are forced
|
|
/// to handle the absence of a real prediction (e.g. the gRPC layer returns
|
|
/// `Status::unavailable` or `Status::internal`).
|
|
async fn get_fallback_trading_signal(
|
|
&self,
|
|
symbol: &str,
|
|
) -> TradingServiceResult<EnsembleTradingSignal> {
|
|
tracing::warn!(
|
|
symbol = %symbol,
|
|
"No ML prediction available: ensemble coordinator unavailable or \
|
|
feature extraction / inference failed. Refusing to fabricate a \
|
|
trading signal."
|
|
);
|
|
|
|
Err(TradingServiceError::MLModel {
|
|
model_name: "ensemble".to_string(),
|
|
message: format!(
|
|
"No ML prediction available for {symbol}: ensemble coordinator \
|
|
unavailable or inference failed"
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Trading signal with ensemble attribution
|
|
#[derive(Debug, Clone)]
|
|
pub struct EnsembleTradingSignal {
|
|
pub symbol: String,
|
|
pub action: TradingActionType,
|
|
pub confidence: f64,
|
|
pub position_size: u64,
|
|
pub disagreement_rate: f64,
|
|
pub model_votes: std::collections::HashMap<String, ml::ensemble::ModelVote>,
|
|
pub timestamp: std::time::SystemTime,
|
|
}
|
|
|
|
/// Trading action type
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TradingActionType {
|
|
Buy,
|
|
Sell,
|
|
Hold,
|
|
}
|
|
|
|
/// Risk management engine wired to the real `risk` crate VaR calculators.
|
|
///
|
|
/// Provides two levels of VaR calculation:
|
|
/// - **Marginal VaR** via `risk::risk_engine::VarEngine` -- fast, parametric,
|
|
/// suitable for pre-trade per-order risk checks.
|
|
/// - **Comprehensive portfolio VaR** via `risk::RealVaREngine` -- multi-methodology
|
|
/// (historical simulation, parametric, Monte Carlo, hybrid) with stress testing
|
|
/// and risk decomposition.
|
|
pub struct RiskEngine {
|
|
/// Fast parametric VaR engine for marginal/per-order risk checks
|
|
var_engine: risk::risk_engine::VarEngine,
|
|
/// Multi-methodology portfolio VaR engine
|
|
portfolio_var_engine: risk::RealVaREngine,
|
|
/// VaR confidence level loaded from config repository (default 0.95)
|
|
var_confidence: f64,
|
|
/// Maximum VaR limit loaded from config repository
|
|
max_var_limit: f64,
|
|
}
|
|
|
|
impl std::fmt::Debug for RiskEngine {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("RiskEngine")
|
|
.field("var_confidence", &self.var_confidence)
|
|
.field("max_var_limit", &self.max_var_limit)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Default for RiskEngine {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl RiskEngine {
|
|
/// Create a new `RiskEngine` backed by real VaR calculators from the `risk` crate.
|
|
pub fn new() -> Self {
|
|
let var_config = config::structures::VarConfig::default();
|
|
let var_engine = risk::risk_engine::VarEngine::with_defaults(var_config);
|
|
let portfolio_var_engine = risk::RealVaREngine::new();
|
|
Self {
|
|
var_engine,
|
|
portfolio_var_engine,
|
|
var_confidence: 0.95,
|
|
max_var_limit: 100_000.0,
|
|
}
|
|
}
|
|
|
|
/// Initialize the risk engine with parameters from the config repository.
|
|
///
|
|
/// Loads `var_confidence` and `max_var_limit` from the "Risk" config category.
|
|
/// If a value is missing the default is retained.
|
|
pub async fn initialize_with_config_repository(
|
|
&mut self,
|
|
config_repository: &Arc<PostgresConfigRepository>,
|
|
) -> TradingServiceResult<()> {
|
|
// Load VaR confidence level from config repository
|
|
if let Ok(Some(var_confidence)) = config_repository
|
|
.get_config_f64("Risk", "var_confidence")
|
|
.await
|
|
{
|
|
if (0.0..=1.0).contains(&var_confidence) {
|
|
self.var_confidence = var_confidence;
|
|
tracing::info!(
|
|
"Risk engine VaR confidence set to: {}",
|
|
var_confidence
|
|
);
|
|
} else {
|
|
tracing::warn!(
|
|
"Invalid VaR confidence {} from config, keeping default {}",
|
|
var_confidence,
|
|
self.var_confidence
|
|
);
|
|
}
|
|
}
|
|
|
|
// Load max VaR limit from config repository
|
|
if let Ok(Some(max_var_limit)) = config_repository
|
|
.get_config_f64("Risk", "max_var_limit")
|
|
.await
|
|
{
|
|
if max_var_limit > 0.0 {
|
|
self.max_var_limit = max_var_limit;
|
|
tracing::info!(
|
|
"Risk engine max VaR limit set to: {}",
|
|
max_var_limit
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rebuild var_engine with updated configuration
|
|
let var_config = config::structures::VarConfig {
|
|
confidence_level: self.var_confidence,
|
|
max_var_limit: self.max_var_limit,
|
|
..config::structures::VarConfig::default()
|
|
};
|
|
self.var_engine = risk::risk_engine::VarEngine::with_defaults(var_config);
|
|
|
|
tracing::info!(
|
|
"RiskEngine initialized with real VaR calculators (confidence={}, max_limit={})",
|
|
self.var_confidence,
|
|
self.max_var_limit
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Calculate the marginal Value at Risk for a proposed order.
|
|
///
|
|
/// Delegates to `risk::risk_engine::VarEngine::calculate_marginal_var` which
|
|
/// computes VaR = position_value * daily_volatility * z_score(confidence).
|
|
///
|
|
/// Returns the marginal VaR as `f64` for easy use in the trading service.
|
|
pub async fn calculate_marginal_var(
|
|
&self,
|
|
account_id: &str,
|
|
instrument_id: &str,
|
|
quantity: f64,
|
|
price: f64,
|
|
) -> Result<f64, String> {
|
|
let quantity_decimal = rust_decimal::Decimal::try_from(quantity)
|
|
.map_err(|e| format!("Invalid quantity for VaR: {e}"))?;
|
|
let price_decimal = rust_decimal::Decimal::try_from(price)
|
|
.map_err(|e| format!("Invalid price for VaR: {e}"))?;
|
|
|
|
let marginal_var = self
|
|
.var_engine
|
|
.calculate_marginal_var(account_id, instrument_id, quantity_decimal, price_decimal)
|
|
.await
|
|
.map_err(|e| format!("VaR calculation failed: {e}"))?;
|
|
|
|
use num_traits::ToPrimitive;
|
|
marginal_var
|
|
.to_f64()
|
|
.ok_or_else(|| "Failed to convert VaR Decimal to f64".to_string())
|
|
}
|
|
|
|
/// Check whether a proposed order's VaR impact exceeds the configured limit.
|
|
///
|
|
/// Returns `Ok(())` if within limits, or `Err(message)` describing the breach.
|
|
pub async fn check_var_limit(
|
|
&self,
|
|
account_id: &str,
|
|
instrument_id: &str,
|
|
quantity: f64,
|
|
price: f64,
|
|
) -> Result<(), String> {
|
|
let marginal_var = self
|
|
.calculate_marginal_var(account_id, instrument_id, quantity, price)
|
|
.await?;
|
|
|
|
if marginal_var > self.max_var_limit {
|
|
Err(format!(
|
|
"VaR limit exceeded: marginal VaR {:.2} > limit {:.2}",
|
|
marginal_var, self.max_var_limit
|
|
))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Get a reference to the comprehensive portfolio VaR engine.
|
|
///
|
|
/// Callers can use this to run `calculate_comprehensive_var` and
|
|
/// `check_circuit_breaker_conditions` from `risk::RealVaREngine`.
|
|
pub fn portfolio_var_engine(&self) -> &risk::RealVaREngine {
|
|
&self.portfolio_var_engine
|
|
}
|
|
|
|
/// Get the current VaR confidence level.
|
|
pub fn var_confidence(&self) -> f64 {
|
|
self.var_confidence
|
|
}
|
|
|
|
/// Get the current maximum VaR limit.
|
|
pub fn max_var_limit(&self) -> f64 {
|
|
self.max_var_limit
|
|
}
|
|
}
|
|
|
|
/// `Position` state validator
|
|
#[derive(Debug, Default)]
|
|
pub struct PositionStateValidator {
|
|
// Position validation logic
|
|
}
|
|
|
|
impl PositionStateValidator {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub async fn initialize_with_config_repository(
|
|
&mut self,
|
|
config_repository: &Arc<PostgresConfigRepository>,
|
|
) -> TradingServiceResult<()> {
|
|
// Initialize risk parameters from repository (no direct database access)
|
|
// Load VaR confidence from config repository
|
|
if let Ok(Some(var_confidence)) = config_repository
|
|
.get_config_f64("Risk", "var_confidence")
|
|
.await
|
|
{
|
|
tracing::info!(
|
|
"Risk engine initialized with VaR confidence: {}",
|
|
var_confidence
|
|
);
|
|
}
|
|
|
|
// Load max drawdown limit from config repository
|
|
if let Ok(Some(max_drawdown)) = config_repository
|
|
.get_config_f64("Risk", "max_drawdown_limit")
|
|
.await
|
|
{
|
|
tracing::info!(
|
|
"Risk engine initialized with max drawdown limit: {}",
|
|
max_drawdown
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// ML engine backed by the real `ml::ensemble::EnsembleCoordinator`.
|
|
///
|
|
/// The coordinator is lazily initialized during
|
|
/// [`initialize_with_config_repository`](MLEngine::initialize_with_config_repository)
|
|
/// because it needs async model registration. Before that call,
|
|
/// [`coordinator()`](MLEngine::coordinator) returns `None`.
|
|
#[derive(Default)]
|
|
pub struct MLEngine {
|
|
/// The real ensemble coordinator from the `ml` crate.
|
|
/// `None` until [`initialize_with_config_repository`] completes.
|
|
coordinator: Option<ml::ensemble::EnsembleCoordinator>,
|
|
}
|
|
|
|
impl std::fmt::Debug for MLEngine {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("MLEngine")
|
|
.field("coordinator", &self.coordinator)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
|
|
impl MLEngine {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Initialize the ML engine by creating an `EnsembleCoordinator` and
|
|
/// registering the default production models (DQN, PPO, TFT, MAMBA-2).
|
|
///
|
|
/// Model weights and the inference timeout are loaded from the config
|
|
/// repository. If a config key is missing the default value is used.
|
|
pub async fn initialize_with_config_repository(
|
|
&mut self,
|
|
config_repository: &Arc<PostgresConfigRepository>,
|
|
) -> TradingServiceResult<()> {
|
|
// Load ML inference timeout from config repository
|
|
if let Ok(Some(inference_timeout)) = config_repository
|
|
.get_config_u64("MachineLearning", "inference_timeout_ms")
|
|
.await
|
|
{
|
|
tracing::info!(
|
|
"ML engine initialized with inference timeout: {}ms",
|
|
inference_timeout
|
|
);
|
|
}
|
|
|
|
// Create the real ensemble coordinator
|
|
let coordinator = ml::ensemble::EnsembleCoordinator::new();
|
|
|
|
// Load per-model weights from config, falling back to equal weights
|
|
let default_weight = 0.25_f64;
|
|
|
|
let dqn_weight = config_repository
|
|
.get_config_f64("MachineLearning", "dqn_weight")
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(default_weight);
|
|
|
|
let ppo_weight = config_repository
|
|
.get_config_f64("MachineLearning", "ppo_weight")
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(default_weight);
|
|
|
|
let tft_weight = config_repository
|
|
.get_config_f64("MachineLearning", "tft_weight")
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(default_weight);
|
|
|
|
let mamba2_weight = config_repository
|
|
.get_config_f64("MachineLearning", "mamba2_weight")
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or(default_weight);
|
|
|
|
// Register the four production models
|
|
if let Err(e) = coordinator
|
|
.register_model("DQN".to_string(), dqn_weight)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to register DQN model: {}", e);
|
|
}
|
|
if let Err(e) = coordinator
|
|
.register_model("PPO".to_string(), ppo_weight)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to register PPO model: {}", e);
|
|
}
|
|
if let Err(e) = coordinator
|
|
.register_model("TFT".to_string(), tft_weight)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to register TFT model: {}", e);
|
|
}
|
|
if let Err(e) = coordinator
|
|
.register_model("MAMBA-2".to_string(), mamba2_weight)
|
|
.await
|
|
{
|
|
tracing::error!("Failed to register MAMBA-2 model: {}", e);
|
|
}
|
|
|
|
let model_count = coordinator.model_count().await;
|
|
tracing::info!(
|
|
"ML engine EnsembleCoordinator initialized with {} models \
|
|
(DQN={:.2}, PPO={:.2}, TFT={:.2}, MAMBA-2={:.2})",
|
|
model_count,
|
|
dqn_weight,
|
|
ppo_weight,
|
|
tft_weight,
|
|
mamba2_weight,
|
|
);
|
|
|
|
self.coordinator = Some(coordinator);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get a shared reference to the underlying `EnsembleCoordinator`.
|
|
///
|
|
/// Returns `None` if the engine has not been initialized yet.
|
|
pub fn coordinator(&self) -> Option<&ml::ensemble::EnsembleCoordinator> {
|
|
self.coordinator.as_ref()
|
|
}
|
|
|
|
/// Get a mutable reference to the underlying `EnsembleCoordinator`.
|
|
///
|
|
/// Useful for adding inference adapters via
|
|
/// [`EnsembleCoordinator::add_adapter`].
|
|
/// Returns `None` if the engine has not been initialized yet.
|
|
pub fn coordinator_mut(&mut self) -> Option<&mut ml::ensemble::EnsembleCoordinator> {
|
|
self.coordinator.as_mut()
|
|
}
|
|
}
|
|
|
|
/// Market data manager with multiple providers
|
|
pub struct MarketDataManager {
|
|
/// Databento provider for market data
|
|
databento_provider:
|
|
Option<Arc<RwLock<data::providers::databento_streaming::DatabentoStreamingProvider>>>,
|
|
/// Benzinga provider for news data
|
|
benzinga_provider: Option<
|
|
Arc<RwLock<data::providers::benzinga::production_streaming::ProductionBenzingaProvider>>,
|
|
>,
|
|
/// Unified feature extractor
|
|
feature_extractor: Option<Arc<ml::features::UnifiedFeatureExtractor>>,
|
|
/// Event broadcast sender
|
|
_event_sender: Arc<
|
|
tokio::sync::broadcast::Sender<trading_engine::trading::data_interface::MarketDataEvent>,
|
|
>,
|
|
}
|
|
|
|
impl std::fmt::Debug for MarketDataManager {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("MarketDataManager")
|
|
.field("databento_provider", &self.databento_provider)
|
|
.field("benzinga_provider", &self.benzinga_provider)
|
|
.field("feature_extractor", &"<UnifiedFeatureExtractor>")
|
|
.field("_event_sender", &self._event_sender)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Default for MarketDataManager {
|
|
fn default() -> Self {
|
|
let (_event_sender, _) = tokio::sync::broadcast::channel(10000);
|
|
Self {
|
|
databento_provider: None,
|
|
benzinga_provider: None,
|
|
feature_extractor: None,
|
|
_event_sender: Arc::new(_event_sender),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MarketDataManager {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub async fn initialize(&mut self) -> TradingServiceResult<()> {
|
|
// Initialize Databento provider if API key is available
|
|
if let Ok(api_key) = std::env::var("DATABENTO_API_KEY") {
|
|
match data::providers::databento_streaming::DatabentoStreamingProvider::new(api_key) {
|
|
Ok(mut provider) => {
|
|
if let Err(e) = provider.connect().await {
|
|
tracing::info!("Databento connection not available: {}", e);
|
|
} else {
|
|
tracing::info!("Connected to Databento successfully");
|
|
self.databento_provider = Some(Arc::new(RwLock::new(provider)));
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Failed to create Databento provider: {}", e);
|
|
},
|
|
}
|
|
} else {
|
|
tracing::info!("DATABENTO_API_KEY not found, skipping Databento provider");
|
|
}
|
|
|
|
// Initialize Benzinga provider if API key is available
|
|
if let Ok(api_key) = std::env::var("BENZINGA_API_KEY") {
|
|
let benzinga_config =
|
|
data::providers::benzinga::production_streaming::ProductionBenzingaConfig {
|
|
api_key,
|
|
websocket_url: "wss://api.benzinga.com/api/v1/news/stream".to_string(),
|
|
connect_timeout_secs: 30,
|
|
ping_interval_secs: 30,
|
|
max_reconnect_attempts: 3,
|
|
initial_reconnect_delay_ms: 1000,
|
|
max_reconnect_delay_ms: 60000,
|
|
reconnect_backoff_multiplier: 2.0,
|
|
enable_news: true,
|
|
enable_sentiment: true,
|
|
enable_ratings: true,
|
|
enable_options: false,
|
|
event_buffer_size: 1000,
|
|
heartbeat_timeout_secs: 300,
|
|
rate_limit_per_second: 10,
|
|
dedup_window_secs: 60,
|
|
max_dedup_cache_size: 10000,
|
|
enable_compression: true,
|
|
batch_processing_size: 100,
|
|
enable_smart_categorization: true,
|
|
enable_ml_integration: true,
|
|
circuit_breaker_threshold: 5,
|
|
circuit_breaker_timeout_secs: 300,
|
|
max_concurrent_processing: 4,
|
|
};
|
|
|
|
match data::providers::benzinga::production_streaming::ProductionBenzingaProvider::new(
|
|
benzinga_config,
|
|
) {
|
|
Ok(mut provider) => {
|
|
if let Err(e) = provider.connect().await {
|
|
tracing::info!("Benzinga connection not available: {}", e);
|
|
} else {
|
|
tracing::info!("Connected to Benzinga successfully");
|
|
self.benzinga_provider = Some(Arc::new(RwLock::new(provider)));
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Failed to create Benzinga provider: {}", e);
|
|
},
|
|
}
|
|
} else {
|
|
tracing::info!("BENZINGA_API_KEY not found, skipping Benzinga provider");
|
|
}
|
|
|
|
// Initialize UnifiedFeatureExtractor
|
|
let config = ml::features::FeatureExtractionConfig::default();
|
|
let safety_manager = Arc::new(ml::safety::MLSafetyManager::new(
|
|
ml::safety::MLSafetyConfig::default(),
|
|
));
|
|
self.feature_extractor = Some(Arc::new(ml::features::UnifiedFeatureExtractor::new(
|
|
config,
|
|
safety_manager,
|
|
)));
|
|
|
|
tracing::info!("MarketDataManager initialized with available providers");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn initialize_with_config_repository(
|
|
&mut self,
|
|
config_repository: &Arc<PostgresConfigRepository>,
|
|
) -> TradingServiceResult<()> {
|
|
// Initialize market data providers using repository-based configuration (no direct database access)
|
|
|
|
// Get API keys from config repository
|
|
if let Ok(Some(databento_key)) = config_repository.get_secret("databento_api_key").await {
|
|
match data::providers::databento_streaming::DatabentoStreamingProvider::new(
|
|
databento_key,
|
|
) {
|
|
Ok(mut provider) => {
|
|
if let Err(e) = provider.connect().await {
|
|
tracing::info!("Databento connection not available: {}", e);
|
|
} else {
|
|
tracing::info!("Connected to Databento successfully via config repository");
|
|
self.databento_provider = Some(Arc::new(RwLock::new(provider)));
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Failed to create Databento provider: {}", e);
|
|
},
|
|
}
|
|
} else {
|
|
tracing::info!("Databento API key not found in config repository, skipping provider");
|
|
}
|
|
|
|
if let Ok(Some(benzinga_key)) = config_repository.get_secret("benzinga_api_key").await {
|
|
let benzinga_config =
|
|
data::providers::benzinga::production_streaming::ProductionBenzingaConfig {
|
|
api_key: benzinga_key,
|
|
websocket_url: "wss://api.benzinga.com/api/v1/news/stream".to_string(),
|
|
connect_timeout_secs: 30,
|
|
ping_interval_secs: 30,
|
|
max_reconnect_attempts: 3,
|
|
initial_reconnect_delay_ms: 1000,
|
|
max_reconnect_delay_ms: 60000,
|
|
reconnect_backoff_multiplier: 2.0,
|
|
enable_news: true,
|
|
enable_sentiment: true,
|
|
enable_ratings: true,
|
|
enable_options: false,
|
|
event_buffer_size: 1000,
|
|
heartbeat_timeout_secs: 300,
|
|
rate_limit_per_second: 10,
|
|
dedup_window_secs: 60,
|
|
max_dedup_cache_size: 10000,
|
|
enable_compression: true,
|
|
batch_processing_size: 100,
|
|
enable_smart_categorization: true,
|
|
enable_ml_integration: true,
|
|
circuit_breaker_threshold: 5,
|
|
circuit_breaker_timeout_secs: 300,
|
|
max_concurrent_processing: 4,
|
|
};
|
|
match data::providers::benzinga::production_streaming::ProductionBenzingaProvider::new(
|
|
benzinga_config,
|
|
) {
|
|
Ok(mut provider) => {
|
|
if let Err(e) = provider.connect().await {
|
|
tracing::info!("Benzinga connection not available: {}", e);
|
|
} else {
|
|
tracing::info!("Connected to Benzinga successfully via config repository");
|
|
self.benzinga_provider = Some(Arc::new(RwLock::new(provider)));
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Failed to create Benzinga provider: {}", e);
|
|
},
|
|
}
|
|
} else {
|
|
tracing::info!("Benzinga API key not found in config repository, skipping provider");
|
|
}
|
|
|
|
// Initialize UnifiedFeatureExtractor with configuration
|
|
let config = ml::features::FeatureExtractionConfig::default();
|
|
let safety_manager = Arc::new(ml::safety::MLSafetyManager::new(
|
|
ml::safety::MLSafetyConfig::default(),
|
|
));
|
|
self.feature_extractor = Some(Arc::new(ml::features::UnifiedFeatureExtractor::new(
|
|
config,
|
|
safety_manager,
|
|
)));
|
|
|
|
tracing::info!("MarketDataManager initialized with repository-based configuration");
|
|
Ok(())
|
|
}
|
|
|
|
/// Subscribe to market data for given symbols
|
|
pub async fn subscribe_to_symbols(
|
|
&mut self,
|
|
symbols: Vec<common::types::Symbol>,
|
|
) -> TradingServiceResult<()> {
|
|
// Subscribe via Databento provider
|
|
if let Some(databento) = &self.databento_provider {
|
|
let mut provider = databento.write().await;
|
|
let symbol_strings: Vec<String> = symbols.iter().map(|s| s.to_string()).collect();
|
|
if let Err(e) = provider.subscribe(symbol_strings).await {
|
|
tracing::error!("Failed to subscribe to Databento: {}", e);
|
|
} else {
|
|
tracing::info!("Subscribed to {} symbols on Databento", symbols.len());
|
|
}
|
|
}
|
|
|
|
// Subscribe via Benzinga provider
|
|
if let Some(benzinga) = &self.benzinga_provider {
|
|
let mut provider = benzinga.write().await;
|
|
if let Err(e) = provider.subscribe(symbols.clone()).await {
|
|
tracing::error!("Failed to subscribe to Benzinga news: {}", e);
|
|
} else {
|
|
tracing::info!(
|
|
"Subscribed to news for {} symbols on Benzinga",
|
|
symbols.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get market data event receiver
|
|
pub fn get_event_receiver(
|
|
&self,
|
|
) -> tokio::sync::broadcast::Receiver<trading_engine::trading::data_interface::MarketDataEvent>
|
|
{
|
|
self._event_sender.subscribe()
|
|
}
|
|
|
|
/// Start event processing from all providers
|
|
pub async fn start_event_processing(&self) -> TradingServiceResult<()> {
|
|
// Start processing events from Databento
|
|
if let Some(databento) = &self.databento_provider {
|
|
let provider = Arc::clone(databento);
|
|
let _event_sender = Arc::clone(&self._event_sender);
|
|
let feature_extractor = self.feature_extractor.clone();
|
|
|
|
tokio::spawn(async move {
|
|
let databento_provider = provider.read().await;
|
|
let mut event_receiver = databento_provider.subscribe_market_events();
|
|
drop(databento_provider); // Release the read lock
|
|
|
|
while let Ok(event) = event_receiver.recv().await {
|
|
// Process event through feature extractor if available
|
|
if let Some(_extractor) = &feature_extractor {
|
|
// ROADMAP: Feature extraction pipeline integration
|
|
// ------------------------------------------------
|
|
// The feature extractor is instantiated but not yet wired into the
|
|
// market-event loop. To complete the integration:
|
|
// 1. Aggregate raw ticks into OHLCV bars (1m, 5m, 15m) via a
|
|
// BarAggregator that buffers ticks per symbol and emits bars
|
|
// on period boundaries.
|
|
// 2. Feed completed bars into the FeatureExtractor to produce
|
|
// the 51-dim feature vector (5 OHLCV + 21 technical indicators
|
|
// + 25 microstructure features) expected by the ML models.
|
|
// 3. Cache the latest feature vector per symbol in an Arc<DashMap>
|
|
// so the EnsembleCoordinator can read it without re-computing.
|
|
// 4. Depends on: MarketDataRepository gaining a `get_ohlcv_bars()`
|
|
// method (see extract_features_for_symbol roadmap in this file).
|
|
tracing::debug!("Processing market event through feature extractor");
|
|
}
|
|
|
|
// Forward event to subscribers
|
|
let _ = _event_sender.send(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start processing events from Benzinga
|
|
if let Some(benzinga) = &self.benzinga_provider {
|
|
let provider = Arc::clone(benzinga);
|
|
let _event_sender = Arc::clone(&self._event_sender);
|
|
|
|
tokio::spawn(async move {
|
|
let mut benzinga_provider = provider.write().await;
|
|
match benzinga_provider.stream().await {
|
|
Ok(mut stream) => {
|
|
drop(benzinga_provider); // Release the write lock
|
|
|
|
// Process events from the stream
|
|
while let Some(event) = stream.next().await {
|
|
// Forward news-derived market events to subscribers
|
|
let _ = _event_sender.send(event);
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::error!("Failed to get Benzinga stream: {}", e);
|
|
},
|
|
}
|
|
});
|
|
}
|
|
|
|
tracing::info!("Started event processing for all connected providers");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get health status of all providers
|
|
pub async fn get_provider_health(
|
|
&self,
|
|
) -> Vec<(String, data::providers::ProviderHealthStatus)> {
|
|
let mut health_status = Vec::new();
|
|
|
|
if let Some(databento) = &self.databento_provider {
|
|
let provider = databento.read().await;
|
|
health_status.push(("databento".to_string(), provider.get_health_status()));
|
|
}
|
|
|
|
if let Some(benzinga) = &self.benzinga_provider {
|
|
let provider = benzinga.read().await;
|
|
// Create ProviderHealthStatus from ConnectionStatus
|
|
let connection_status = provider.get_connection_status();
|
|
let health = data::providers::ProviderHealthStatus {
|
|
connected: matches!(
|
|
connection_status.state,
|
|
data::providers::ConnectionState::Connected
|
|
),
|
|
last_connected: connection_status.last_connection_attempt,
|
|
active_subscriptions: connection_status.active_subscriptions,
|
|
messages_per_second: connection_status.events_per_second,
|
|
latency_micros: connection_status.latency_micros,
|
|
error_count: 0, // Default value
|
|
};
|
|
health_status.push(("benzinga".to_string(), health));
|
|
}
|
|
|
|
health_status
|
|
}
|
|
}
|
|
/// Health status enumeration
|
|
#[derive(Debug, Clone)]
|
|
pub enum HealthStatus {
|
|
/// All systems operational
|
|
Healthy,
|
|
/// Some degraded performance
|
|
Degraded,
|
|
/// Critical issues present
|
|
Unhealthy,
|
|
/// Service offline
|
|
Critical,
|
|
}
|
|
|
|
/// Ensemble coordinator health status
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EnsembleHealth {
|
|
/// All models loaded and operational
|
|
Healthy,
|
|
/// Some models missing or degraded performance
|
|
Degraded,
|
|
/// Critical issues (no models loaded, inference failing)
|
|
Unhealthy,
|
|
/// Ensemble coordinator not configured (fallback mode)
|
|
NotConfigured,
|
|
}
|
|
|
|
/// Detailed ensemble health report
|
|
#[derive(Debug, Clone)]
|
|
pub struct EnsembleHealthReport {
|
|
pub status: EnsembleHealth,
|
|
pub models_loaded: usize,
|
|
pub expected_models: usize,
|
|
pub inference_latency_us: Option<f64>,
|
|
pub last_prediction: Option<std::time::SystemTime>,
|
|
pub model_details: Vec<ModelHealthDetail>,
|
|
}
|
|
|
|
/// Per-model health detail
|
|
#[derive(Debug, Clone)]
|
|
pub struct ModelHealthDetail {
|
|
pub model_id: String,
|
|
pub checkpoint: String,
|
|
pub loaded: bool,
|
|
pub last_inference: std::time::SystemTime,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_risk_engine_creation() {
|
|
let engine = RiskEngine::new();
|
|
// Default confidence level is 0.95
|
|
assert!((engine.var_confidence() - 0.95).abs() < f64::EPSILON);
|
|
// Default max VaR limit is 100_000.0
|
|
assert!((engine.max_var_limit() - 100_000.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn test_risk_engine_default() {
|
|
let engine = RiskEngine::default();
|
|
assert!((engine.var_confidence() - 0.95).abs() < f64::EPSILON);
|
|
assert!((engine.max_var_limit() - 100_000.0).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn test_risk_engine_debug() {
|
|
let engine = RiskEngine::new();
|
|
let debug_str = format!("{engine:?}");
|
|
assert!(debug_str.contains("RiskEngine"));
|
|
assert!(debug_str.contains("var_confidence"));
|
|
assert!(debug_str.contains("max_var_limit"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_portfolio_var_engine_accessible() {
|
|
let engine = RiskEngine::new();
|
|
// Verify the portfolio VaR engine is accessible and functional
|
|
let _portfolio_engine = engine.portfolio_var_engine();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_marginal_var_calculation() {
|
|
let engine = RiskEngine::new();
|
|
// AAPL at $175, 100 shares -- should produce a positive VaR
|
|
let result = engine
|
|
.calculate_marginal_var("test_account", "AAPL", 100.0, 175.0)
|
|
.await;
|
|
assert!(result.is_ok(), "Marginal VaR calculation failed: {result:?}");
|
|
let var_value = result.unwrap_or(0.0);
|
|
assert!(var_value > 0.0, "Marginal VaR should be positive, got {var_value}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_marginal_var_crypto_higher_than_equity() {
|
|
let engine = RiskEngine::new();
|
|
// Crypto (BTC) should have higher volatility than equity (AAPL)
|
|
// Same notional value for comparison
|
|
let btc_var = engine
|
|
.calculate_marginal_var("test_account", "BTC", 1.0, 50_000.0)
|
|
.await
|
|
.unwrap_or(0.0);
|
|
let aapl_var = engine
|
|
.calculate_marginal_var("test_account", "AAPL", 285.7, 175.0)
|
|
.await
|
|
.unwrap_or(0.0);
|
|
// Both should be non-zero and BTC should have higher VaR
|
|
assert!(btc_var > 0.0, "BTC VaR should be positive");
|
|
assert!(aapl_var > 0.0, "AAPL VaR should be positive");
|
|
assert!(
|
|
btc_var > aapl_var,
|
|
"BTC VaR ({btc_var:.2}) should exceed AAPL VaR ({aapl_var:.2})"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_check_var_limit_within_limit() {
|
|
let engine = RiskEngine::new();
|
|
// Small order should be within the 100k default limit
|
|
let result = engine
|
|
.check_var_limit("test_account", "AAPL", 10.0, 175.0)
|
|
.await;
|
|
assert!(result.is_ok(), "Small order should pass VaR limit check");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_check_var_limit_exceeds_limit() {
|
|
let mut engine = RiskEngine::new();
|
|
// Set a very low VaR limit
|
|
engine.max_var_limit = 1.0;
|
|
let result = engine
|
|
.check_var_limit("test_account", "AAPL", 1000.0, 175.0)
|
|
.await;
|
|
assert!(result.is_err(), "Large order should breach tiny VaR limit");
|
|
let err_msg = result.unwrap_err();
|
|
assert!(
|
|
err_msg.contains("VaR limit exceeded"),
|
|
"Error message should describe VaR breach: {err_msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_marginal_var_invalid_inputs() {
|
|
let engine = RiskEngine::new();
|
|
// Zero quantity should still work (VaR = 0 -> non-positive -> error from risk crate)
|
|
let result = engine
|
|
.calculate_marginal_var("test_account", "AAPL", 0.0, 175.0)
|
|
.await;
|
|
// The risk crate rejects non-positive VaR results, so this should fail
|
|
assert!(result.is_err(), "Zero quantity should produce an error");
|
|
}
|
|
}
|