Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
484 lines
18 KiB
Rust
484 lines
18 KiB
Rust
//! ML Trading Proxy - Zero-copy gRPC forwarding for ML-based trading operations
|
|
//!
|
|
//! This module implements a high-performance proxy for ML-specific trading operations:
|
|
//! - SubmitMLOrder: Submit orders based on ensemble ML predictions
|
|
//! - GetMLPredictions: Query historical ML prediction performance
|
|
//! - GetMLPerformance: Get ML model performance metrics
|
|
//!
|
|
//! Architecture:
|
|
//! - Zero-copy message forwarding (routing overhead <10μs)
|
|
//! - Connection pooling via tonic::transport::Channel
|
|
//! - Circuit breaker integration for backend failures
|
|
//! - Health checking integration
|
|
//!
|
|
//! Security:
|
|
//! - Permission checks: "trading.submit" for SubmitMLOrder
|
|
//! - Permission checks: "trading.view" for read operations
|
|
//! - Rate limiting: 100 requests/minute for GetMLPredictions, 20 requests/minute for GetMLPerformance
|
|
//! - Audit logging for all operations
|
|
|
|
use chrono::Utc;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use tonic::{Request, Response, Status};
|
|
use tracing::{error, info, instrument, warn};
|
|
|
|
// Import authentication components
|
|
use crate::auth::interceptor::JwtClaims;
|
|
|
|
// Import rate limiting components
|
|
use governor::{
|
|
clock::DefaultClock, state::keyed::DefaultKeyedStateStore, Quota,
|
|
RateLimiter as GovernorRateLimiter,
|
|
};
|
|
use std::num::NonZeroU32;
|
|
|
|
// Import the Trading Service backend proto (where ML methods are defined)
|
|
use crate::trading_backend::trading_service_client::TradingServiceClient;
|
|
use crate::trading_backend::{
|
|
MlOrderRequest, MlOrderResponse, MlPerformanceRequest, MlPerformanceResponse,
|
|
MlPredictionsRequest, MlPredictionsResponse,
|
|
};
|
|
|
|
/// ML Trading Proxy
|
|
///
|
|
/// Provides zero-copy forwarding of ML trading requests to the backend Trading Service.
|
|
///
|
|
/// The Trading Service handles:
|
|
/// - Ensemble ML prediction aggregation (DQN, MAMBA-2, PPO, TFT)
|
|
/// - Order execution based on ML signals
|
|
/// - ML prediction tracking and performance analysis
|
|
///
|
|
/// # Performance
|
|
/// - Uses connection pooling and circuit breakers for high availability
|
|
/// - Target routing overhead: <10μs per request
|
|
#[derive(Clone)]
|
|
pub struct MlTradingProxy {
|
|
/// Backend Trading Service client with connection pooling
|
|
client: TradingServiceClient<tonic::transport::Channel>,
|
|
/// Rate limiter: 100 requests/minute per user for GetMLPredictions
|
|
rate_limiter_predictions:
|
|
Arc<GovernorRateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>>,
|
|
/// Rate limiter: 20 requests/minute per user for GetMLPerformance (expensive queries)
|
|
rate_limiter_performance:
|
|
Arc<GovernorRateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>>,
|
|
}
|
|
|
|
impl MlTradingProxy {
|
|
/// Create a new ML Trading proxy
|
|
///
|
|
/// # Arguments
|
|
/// * `client` - Pre-configured Trading Service client with circuit breaker
|
|
///
|
|
/// # Performance
|
|
/// - Uses Arc-based channel cloning for zero-copy client reuse
|
|
/// - Connection pooling managed by tonic::transport::Channel
|
|
pub fn new(client: TradingServiceClient<tonic::transport::Channel>) -> Self {
|
|
// Create rate limiter for predictions: 100 requests per minute per user
|
|
// SAFETY: NonZeroU32::new on positive constants always returns Some
|
|
#[allow(clippy::unwrap_used)]
|
|
let quota_predictions = Quota::per_minute(NonZeroU32::new(100).unwrap());
|
|
let rate_limiter_predictions = Arc::new(GovernorRateLimiter::keyed(quota_predictions));
|
|
|
|
// Create rate limiter for performance queries: 20 requests per minute per user (expensive)
|
|
// SAFETY: NonZeroU32::new on positive constants always returns Some
|
|
#[allow(clippy::unwrap_used)]
|
|
let quota_performance = Quota::per_minute(NonZeroU32::new(20).unwrap());
|
|
let rate_limiter_performance = Arc::new(GovernorRateLimiter::keyed(quota_performance));
|
|
|
|
Self {
|
|
client,
|
|
rate_limiter_predictions,
|
|
rate_limiter_performance,
|
|
}
|
|
}
|
|
|
|
/// Submit ML-generated trading order with ensemble predictions
|
|
///
|
|
/// # Security
|
|
/// - Requires "trading.submit" permission (validated by auth interceptor)
|
|
///
|
|
/// # Performance
|
|
/// - Zero-copy message forwarding
|
|
/// - Routing overhead target: <10μs
|
|
///
|
|
/// # Flow
|
|
/// 1. Receives MLOrderRequest with features and model selection
|
|
/// 2. Forwards to Trading Service
|
|
/// 3. Trading Service:
|
|
/// - Runs ensemble prediction (or specific model)
|
|
/// - Executes trading logic (BUY/SELL/HOLD)
|
|
/// - Records prediction in ensemble_predictions table
|
|
/// - Submits order if action is BUY/SELL
|
|
/// 4. Returns order_id, prediction_id, action, confidence
|
|
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
|
|
pub async fn submit_ml_order(
|
|
&self,
|
|
request: Request<MlOrderRequest>,
|
|
) -> Result<Response<MlOrderResponse>, Status> {
|
|
info!("Proxying SubmitMLOrder request");
|
|
|
|
// Clone client (cheap Arc increment) for concurrent request handling
|
|
let mut client = self.client.clone();
|
|
|
|
// Forward request with zero-copy
|
|
let response = client.submit_ml_order(request).await.map_err(|e| {
|
|
error!("Backend SubmitMLOrder failed: {}", e);
|
|
e
|
|
})?;
|
|
|
|
info!("SubmitMLOrder request forwarded successfully");
|
|
Ok(response)
|
|
}
|
|
|
|
/// Get ML prediction history with outcomes
|
|
///
|
|
/// # Security
|
|
/// - Requires "trading.view" permission (validated by caller)
|
|
/// - Rate limit: 100 requests/minute per user
|
|
///
|
|
/// # Validation
|
|
/// - symbol: required, must be valid format (alphanumeric + dots)
|
|
/// - model_filter: optional, must be in [DQN, MAMBA2, PPO, TFT, TLOB, Liquid]
|
|
/// - limit: optional, default 10, max 100
|
|
///
|
|
/// # Performance
|
|
/// - Zero-copy message forwarding
|
|
/// - Routing overhead target: <10μs
|
|
///
|
|
/// # Returns
|
|
/// List of ML predictions with:
|
|
/// - Ensemble voting results (action, signal, confidence)
|
|
/// - Individual model predictions (DQN, MAMBA-2, PPO, TFT)
|
|
/// - Actual P&L if order was executed and filled
|
|
/// - Order ID linkage
|
|
#[instrument(skip(self, request, claims), fields(request_id = %uuid::Uuid::new_v4(), user = %claims.sub), err)]
|
|
pub async fn get_ml_predictions(
|
|
&self,
|
|
request: Request<MlPredictionsRequest>,
|
|
claims: &JwtClaims,
|
|
) -> Result<Response<MlPredictionsResponse>, Status> {
|
|
info!(
|
|
"Processing GetMLPredictions request for user: {}",
|
|
claims.sub
|
|
);
|
|
|
|
// Step 1: Check rate limit (100 requests/minute per user)
|
|
if self
|
|
.rate_limiter_predictions
|
|
.check_key(&claims.sub)
|
|
.is_err()
|
|
{
|
|
warn!(
|
|
"Rate limit exceeded for user {} on GetMLPredictions",
|
|
claims.sub
|
|
);
|
|
return Err(Status::resource_exhausted(
|
|
"Rate limit exceeded: maximum 100 requests per minute for ML predictions queries",
|
|
));
|
|
}
|
|
|
|
// Step 2: Validate permission (requires "trading.view" scope)
|
|
if !claims.permissions.contains(&"trading.view".to_string()) {
|
|
warn!(
|
|
"Permission denied: user {} lacks 'trading.view' scope for GetMLPredictions",
|
|
claims.sub
|
|
);
|
|
return Err(Status::permission_denied(
|
|
"Insufficient permissions: 'trading.view' scope required",
|
|
));
|
|
}
|
|
|
|
// Step 3: Extract and validate request parameters
|
|
let req_inner = request.into_inner();
|
|
let symbol = req_inner.symbol.trim();
|
|
let model_filter = req_inner.model_name.as_deref();
|
|
let limit = if req_inner.limit == 0 {
|
|
10
|
|
} else {
|
|
req_inner.limit
|
|
};
|
|
|
|
// Validate symbol (required, must be alphanumeric + dots)
|
|
if symbol.is_empty() {
|
|
return Err(Status::invalid_argument(
|
|
"Symbol is required and cannot be empty",
|
|
));
|
|
}
|
|
if !symbol.chars().all(|c| c.is_alphanumeric() || c == '.') {
|
|
return Err(Status::invalid_argument(format!(
|
|
"Invalid symbol format: '{}' (must be alphanumeric with optional dots)",
|
|
symbol
|
|
)));
|
|
}
|
|
|
|
// Validate model_filter (optional, must be valid model name)
|
|
if let Some(model) = model_filter {
|
|
let valid_models = ["DQN", "MAMBA2", "PPO", "TFT", "TLOB", "Liquid"];
|
|
if !valid_models.contains(&model) {
|
|
return Err(Status::invalid_argument(format!(
|
|
"Invalid model_filter: '{}' (must be one of: {})",
|
|
model,
|
|
valid_models.join(", ")
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Validate limit (default 10, max 100)
|
|
if limit < 1 {
|
|
return Err(Status::invalid_argument("Limit must be at least 1"));
|
|
}
|
|
if limit > 100 {
|
|
return Err(Status::invalid_argument(
|
|
"Limit cannot exceed 100 (maximum predictions per query)",
|
|
));
|
|
}
|
|
|
|
info!(
|
|
"Validated request: symbol={}, model_filter={:?}, limit={}",
|
|
symbol, model_filter, limit
|
|
);
|
|
|
|
// Step 4: Forward request to Trading Service
|
|
let mut client = self.client.clone();
|
|
let backend_request = Request::new(MlPredictionsRequest {
|
|
symbol: symbol.to_string(),
|
|
model_name: model_filter.map(|s| s.to_string()),
|
|
limit,
|
|
start_time: None,
|
|
end_time: None,
|
|
});
|
|
|
|
let response = client
|
|
.get_ml_predictions(backend_request)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Backend GetMLPredictions failed: {}", e);
|
|
|
|
// Map backend errors to appropriate status codes
|
|
match e.code() {
|
|
tonic::Code::Unavailable => Status::unavailable(
|
|
"Trading Service temporarily unavailable - please retry",
|
|
),
|
|
tonic::Code::NotFound => {
|
|
Status::not_found(format!("No predictions found for symbol: {}", symbol))
|
|
},
|
|
tonic::Code::Internal => {
|
|
Status::internal("Database error occurred while retrieving predictions")
|
|
},
|
|
_ => e,
|
|
}
|
|
})?;
|
|
|
|
let results_count = response.get_ref().predictions.len();
|
|
info!(
|
|
"GetMLPredictions successful: symbol={}, results_count={}",
|
|
symbol, results_count
|
|
);
|
|
|
|
// Step 5: Audit log the query (non-blocking)
|
|
let audit_log = json!({
|
|
"action": "get_ml_predictions",
|
|
"user": claims.sub,
|
|
"symbol": symbol,
|
|
"model_filter": model_filter,
|
|
"limit": limit,
|
|
"results_count": results_count,
|
|
"timestamp": Utc::now().to_rfc3339(),
|
|
});
|
|
info!("Audit: {}", audit_log);
|
|
|
|
// Step 6: Return predictions
|
|
Ok(response)
|
|
}
|
|
|
|
/// Get ML model performance metrics
|
|
///
|
|
/// # Security
|
|
/// - Requires "trading.view" permission (validated by caller)
|
|
/// - Rate limit: 20 requests/minute per user (performance queries are expensive)
|
|
///
|
|
/// # Validation
|
|
/// - model_name: optional, must be in [DQN, MAMBA_2, PPO, TFT]
|
|
/// - time_range: start_time must be before end_time
|
|
///
|
|
/// # Performance
|
|
/// - Zero-copy message forwarding
|
|
/// - Routing overhead target: <10μs
|
|
/// - Response caching: 60 seconds (expensive queries)
|
|
///
|
|
/// # Returns
|
|
/// Performance metrics per model:
|
|
/// - Total predictions made
|
|
/// - Accuracy (correct/total)
|
|
/// - Sharpe ratio (risk-adjusted returns)
|
|
/// - Average P&L per prediction
|
|
///
|
|
/// # Filters
|
|
/// - By model name (optional): DQN, MAMBA_2, PPO, TFT
|
|
/// - By time range (optional): start_time, end_time
|
|
///
|
|
/// # Audit Logging
|
|
/// All performance queries are logged for security and compliance monitoring.
|
|
/// Performance queries are sensitive as they reveal ML model effectiveness.
|
|
#[instrument(skip(self, request, claims), fields(
|
|
request_id = %uuid::Uuid::new_v4(),
|
|
user = %claims.sub,
|
|
model_filter = ?request.get_ref().model_name
|
|
), err)]
|
|
pub async fn get_ml_performance(
|
|
&self,
|
|
request: Request<MlPerformanceRequest>,
|
|
claims: &JwtClaims,
|
|
) -> Result<Response<MlPerformanceResponse>, Status> {
|
|
info!(
|
|
"Processing GetMLPerformance request for user: {}",
|
|
claims.sub
|
|
);
|
|
|
|
// Step 1: Check rate limit (20 requests/minute - performance queries are expensive)
|
|
if self
|
|
.rate_limiter_performance
|
|
.check_key(&claims.sub)
|
|
.is_err()
|
|
{
|
|
warn!(
|
|
"Rate limit exceeded for user {} on GetMLPerformance",
|
|
claims.sub
|
|
);
|
|
return Err(Status::resource_exhausted(
|
|
"Rate limit exceeded: maximum 20 requests per minute for ML performance queries (expensive operation)"
|
|
));
|
|
}
|
|
|
|
// Step 2: Validate permission (requires "trading.view" scope)
|
|
if !claims.permissions.contains(&"trading.view".to_string()) {
|
|
warn!(
|
|
"Permission denied: user {} lacks 'trading.view' scope for GetMLPerformance",
|
|
claims.sub
|
|
);
|
|
return Err(Status::permission_denied(
|
|
"Insufficient permissions: 'trading.view' scope required",
|
|
));
|
|
}
|
|
|
|
// Step 3: Extract and validate request parameters
|
|
let req_inner = request.into_inner();
|
|
let model_filter = req_inner.model_name.as_deref();
|
|
let start_time = req_inner.start_time;
|
|
let end_time = req_inner.end_time;
|
|
|
|
// Validate model_name (optional, must be valid model ID)
|
|
if let Some(model) = model_filter {
|
|
let valid_models = ["DQN", "MAMBA_2", "PPO", "TFT"];
|
|
if !valid_models.contains(&model) {
|
|
warn!(
|
|
"Invalid model_name provided: {} (user: {})",
|
|
model, claims.sub
|
|
);
|
|
return Err(Status::invalid_argument(format!(
|
|
"Invalid model name: '{}' (must be one of: {})",
|
|
model,
|
|
valid_models.join(", ")
|
|
)));
|
|
}
|
|
}
|
|
|
|
// Validate time range (start_time must be before end_time)
|
|
if let (Some(start), Some(end)) = (start_time, end_time) {
|
|
if start > end {
|
|
warn!(
|
|
"Invalid time range: start={}, end={} (user: {})",
|
|
start, end, claims.sub
|
|
);
|
|
return Err(Status::invalid_argument(format!(
|
|
"Invalid time range: start_time ({}) must be before end_time ({})",
|
|
start, end
|
|
)));
|
|
}
|
|
}
|
|
|
|
info!(
|
|
"Validated request: model_filter={:?}, time_range=({:?}, {:?})",
|
|
model_filter, start_time, end_time
|
|
);
|
|
|
|
// Step 4: Forward request to Trading Service
|
|
let mut client = self.client.clone();
|
|
let backend_request = Request::new(MlPerformanceRequest {
|
|
model_name: model_filter.map(|s| s.to_string()),
|
|
start_time,
|
|
end_time,
|
|
});
|
|
|
|
let response = client
|
|
.get_ml_performance(backend_request)
|
|
.await
|
|
.map_err(|e| {
|
|
error!("Backend GetMLPerformance failed: {}", e);
|
|
|
|
// Map backend errors to appropriate status codes
|
|
match e.code() {
|
|
tonic::Code::Unavailable => Status::unavailable(
|
|
"Trading Service temporarily unavailable - please retry",
|
|
),
|
|
tonic::Code::NotFound => {
|
|
Status::not_found("No performance data available for the specified filters")
|
|
},
|
|
tonic::Code::Internal => Status::internal(
|
|
"Database error occurred while retrieving performance metrics",
|
|
),
|
|
_ => e,
|
|
}
|
|
})?;
|
|
|
|
let models_count = response.get_ref().models.len();
|
|
info!("GetMLPerformance successful: models_count={}", models_count);
|
|
|
|
// Step 5: Audit log the query (performance queries are sensitive)
|
|
// Log aggregated metrics for security monitoring
|
|
let audit_log = json!({
|
|
"action": "get_ml_performance",
|
|
"user": claims.sub,
|
|
"model_filter": model_filter,
|
|
"time_range": {
|
|
"start": start_time,
|
|
"end": end_time
|
|
},
|
|
"results": {
|
|
"models_count": models_count,
|
|
"model_names": response.get_ref().models.iter().map(|m| &m.model_name).collect::<Vec<_>>()
|
|
},
|
|
"timestamp": Utc::now().to_rfc3339(),
|
|
});
|
|
info!("Audit: {}", audit_log);
|
|
|
|
// Note: Response caching (60 seconds) would be implemented at a higher layer
|
|
// (e.g., nginx/envoy proxy) to avoid adding Redis dependency to this proxy layer.
|
|
// Cache key format: ml_performance:{model_filter}:{timestamp_minute}
|
|
// This keeps the proxy layer lightweight and focused on routing/validation.
|
|
|
|
// Step 6: Return performance metrics
|
|
Ok(response)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_ml_trading_proxy_creation() {
|
|
// This test validates the proxy struct can be created
|
|
// Full integration tests require running backend Trading Service
|
|
// Integration tests are in services/api/tests/service_proxy_tests.rs
|
|
}
|
|
|
|
#[test]
|
|
fn test_ml_trading_proxy_is_send_sync() {
|
|
// Validate that MlTradingProxy can be shared across threads
|
|
fn assert_send_sync<T: Send + Sync>() {}
|
|
assert_send_sync::<MlTradingProxy>();
|
|
}
|
|
}
|