feat: fxt 2.0 skeleton — new command tree with 15 command groups

Clean rewrite of the fxt CLI with unified gRPC client, JSON/human
output abstraction, and stub implementations for all 15 command groups:
auth, trade, train, tune, model, agent, backtest, broker, data,
service, cluster, risk, config, watch (TUI), mcp.

Deleted old fat-client commands, client modules, types, prelude, and
tests (-12,769 net lines). Fixed pre-existing clippy issues in auth
module (indexing_slicing in encryption.rs, manual_let_else in
token_manager.rs, str_to_string in build.rs).

60 tests passing (53 lib + 7 binary), 0 clippy warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 21:35:18 +01:00
parent 66d6d0f2a5
commit 724bd64429
50 changed files with 858 additions and 13127 deletions

View File

@@ -27,7 +27,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.server_mod_attribute(".", "#[allow(unused_qualifications)]")
.client_mod_attribute(".", "#[allow(unused_qualifications)]")
.protoc_arg("--experimental_allow_proto3_optional")
.compile_protos(&protos, &[proto_root.to_string()])?;
.compile_protos(&protos, &[proto_root.to_owned()])?;
for proto in &[
"trading",

View File

@@ -230,11 +230,16 @@ pub fn decrypt_token(encrypted: &str, key: &[u8]) -> Result<String, CommonError>
));
}
// Extract nonce (first 12 bytes)
let nonce = Nonce::from_slice(&combined[0..12]);
// Extract nonce (first 12 bytes) -- length validated above (>= 28).
let nonce_bytes = combined.get(..12).ok_or_else(|| {
CommonError::service(ErrorCategory::Security, "Nonce extraction failed".to_owned())
})?;
let nonce = Nonce::from_slice(nonce_bytes);
// Extract ciphertext + tag (remaining bytes, tag is last 16 bytes included in ciphertext)
let ciphertext = &combined[12..];
let ciphertext = combined.get(12..).ok_or_else(|| {
CommonError::service(ErrorCategory::Security, "Ciphertext extraction failed".to_owned())
})?;
// Create AES-256-GCM cipher
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| {

View File

@@ -682,20 +682,17 @@ impl<S: TokenStorage> AuthTokenManager<S> {
/// Check if the token needs refresh (expired or near expiration)
pub async fn needs_refresh(&self) -> bool {
// Read tokens directly from storage (don't use get_current_token which filters expired tokens)
let access_token = match self.storage.get_access_token().await {
Ok(Some(token)) => token,
_ => return false, // No token = no refresh needed
let Ok(Some(access_token)) = self.storage.get_access_token().await else {
return false; // No token = no refresh needed
};
let refresh_token = match self.storage.get_refresh_token().await {
Ok(Some(token)) => token,
_ => return false, // No refresh token = can't refresh
let Ok(Some(refresh_token)) = self.storage.get_refresh_token().await else {
return false; // No refresh token = can't refresh
};
// Extract expiry from access token
let expires_at = match extract_token_expiry(&access_token) {
Ok(exp) => exp,
Err(_) => return false, // Can't parse = assume no refresh needed
let Ok(expires_at) = extract_token_expiry(&access_token) else {
return false; // Can't parse = assume no refresh needed
};
let token_info = TokenInfo {

View File

@@ -1,198 +0,0 @@
//! Backtesting service gRPC client
//!
//! Provides a gRPC client for communicating with the backtesting service,
//! including strategy testing, performance analysis, and historical simulation.
use anyhow::{Context, Result as AnyhowResult};
use serde::{Deserialize, Serialize};
use tonic::transport::Channel;
/// Configuration for the backtesting service gRPC client
///
/// Contains connection parameters and client behavior settings for
/// communicating with the backtesting service.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestingClientConfig {
/// gRPC endpoint URL for the backtesting service (MUST be https://)
pub endpoint: String,
/// Request timeout in milliseconds
pub timeout_ms: u64,
}
impl Default for BacktestingClientConfig {
/// Create default backtesting client configuration
///
/// Returns configuration with API Gateway HTTPS endpoint and 60-second timeout
/// (longer than trading client due to potentially long-running backtests).
///
/// # Security
///
/// Defaults to HTTPS (not HTTP) to enforce encrypted connections.
///
/// # Wave 71 Update
///
/// Endpoint changed to API Gateway (port 50050) instead of direct backtesting service.
///
/// All requests now route through the API Gateway for centralized authentication.
fn default() -> Self {
Self {
endpoint: "https://localhost:50050".to_owned(), // API Gateway endpoint
timeout_ms: 60_000,
}
}
}
/// gRPC client for the backtesting service
///
/// Manages connection to the backtesting service and provides methods for
/// running strategy backtests, retrieving performance metrics, and managing
/// historical simulations.
#[derive(Debug)]
pub struct BacktestingClient {
/// Client configuration
config: BacktestingClientConfig,
/// Active gRPC channel (None if disconnected)
channel: Option<Channel>,
}
impl BacktestingClient {
/// Create a new backtesting client with the specified configuration
///
/// # Arguments
/// * `config` - Client configuration including endpoint and timeout settings
///
/// # Returns
///
/// New `BacktestingClient` instance (not yet connected)
pub const fn new(config: BacktestingClientConfig) -> Self {
Self {
config,
channel: None,
}
}
/// Validate URL scheme is HTTPS (rejects HTTP)
///
/// # Security
///
/// This enforces fail-closed behavior - only HTTPS connections are allowed.
///
/// Insecure HTTP connections are rejected with a clear error message.
fn validate_endpoint_security(endpoint: &str) -> AnyhowResult<()> {
if endpoint.starts_with("http://") {
anyhow::bail!(
"SECURITY ERROR: Insecure HTTP endpoint rejected: {}. TLS (https://) is required for all gRPC connections to protect sensitive backtesting data.",
endpoint
);
}
if !endpoint.starts_with("https://") {
anyhow::bail!(
"Invalid endpoint scheme in URL: {}. Only HTTPS is supported (example: https://backtesting-service:50053).",
endpoint
);
}
Ok(())
}
/// Establish connection to the backtesting service
///
/// Creates a gRPC channel to the configured backtesting service endpoint.
///
/// Must be called before making any service requests.
///
/// # Returns
/// `Result<(), anyhow::Error>` - Ok if connection successful
///
/// # Errors
///
/// Returns error if:
/// - Endpoint uses insecure HTTP scheme (security validation failure)
///
/// - Unable to parse endpoint URL
/// - Unable to connect to the service endpoint
///
/// # Security
///
/// This method enforces TLS by rejecting any HTTP endpoints.
///
/// Use HTTPS endpoints only (e.g., <https://localhost:50053>).
pub async fn connect(&mut self) -> AnyhowResult<()> {
// SECURITY: Validate endpoint uses HTTPS before attempting connection
Self::validate_endpoint_security(&self.config.endpoint)
.context("Backtesting client endpoint security validation failed")?;
// Parse endpoint with proper error handling (no unwrap/panic)
let channel = Channel::from_shared(self.config.endpoint.clone())
.context("Failed to parse backtesting service endpoint URL - check URL format")?
.connect()
.await
.context("Failed to establish connection to backtesting service - check network and TLS configuration")?;
self.channel = Some(channel);
tracing::info!(
"\u{2705} Backtesting client connected securely via TLS to {}",
self.config.endpoint
);
Ok(())
}
/// Check if the client is currently connected to the backtesting service
///
/// # Returns
/// `true` if connected, `false` if disconnected
pub const fn is_connected(&self) -> bool {
self.channel.is_some()
}
/// Shutdown the client and close the connection
///
/// Cleanly closes the gRPC channel and releases associated resources.
///
/// The client can be reconnected after shutdown by calling `connect()`.
pub async fn shutdown(&mut self) {
if self.channel.is_some() {
tracing::info!(
"Shutting down backtesting client connection to {}",
self.config.endpoint
);
}
self.channel = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_uses_https() {
let config = BacktestingClientConfig::default();
assert!(
config.endpoint.starts_with("https://"),
"Default config must use HTTPS"
);
}
#[test]
fn test_http_validation_rejects_insecure() {
let result = BacktestingClient::validate_endpoint_security("http://localhost:50053");
assert!(result.is_err(), "HTTP endpoints should be rejected");
assert!(result.unwrap_err().to_string().contains("Insecure HTTP"));
}
#[test]
fn test_https_validation_accepts_secure() {
let result = BacktestingClient::validate_endpoint_security("https://localhost:50053");
assert!(result.is_ok(), "HTTPS endpoints should be accepted");
}
#[test]
fn test_invalid_scheme_rejected() {
let result = BacktestingClient::validate_endpoint_security("grpc://localhost:50053");
assert!(result.is_err(), "Non-HTTPS schemes should be rejected");
}
}

View File

@@ -1,167 +0,0 @@
//! Connection management for TLI gRPC clients
//!
//! This module provides connection pooling, health monitoring, and statistics
//! tracking for all gRPC client connections to backend services.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Configuration parameters for gRPC client connections
///
/// Contains all settings needed to establish and maintain connections to backend services,
/// including authentication, timeouts, and retry policies.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
/// Server URL for the gRPC service (MUST be https://, e.g., "<https://localhost:50051>")
pub server_url: String,
/// Optional authentication token for secure connections
pub auth_token: Option<String>,
/// Connection timeout in milliseconds
pub timeout_ms: u64,
/// Maximum number of retry attempts for failed requests
pub max_retries: u32,
}
impl Default for ConnectionConfig {
/// Create default configuration for local development
///
/// # Wave 71 Update
///
/// Default endpoint changed to API Gateway (port 50050) instead of direct service connections.
///
/// All TLI traffic now routes through the API Gateway for centralized authentication and routing.
fn default() -> Self {
// SECURITY: Default to HTTPS, not HTTP
// Wave 71: Connect to API Gateway instead of direct service endpoints
Self {
server_url: "https://localhost:50050".to_owned(), // API Gateway endpoint
auth_token: None,
timeout_ms: 10000,
max_retries: 3,
}
}
}
/// Connection statistics and metrics
///
/// Tracks performance metrics and error counts for monitoring
/// connection health and diagnosing issues.
#[derive(Debug, Clone)]
pub struct ConnectionStats {
/// Total number of messages sent to the server
pub messages_sent: u64,
/// Total number of messages received from the server
pub messages_received: u64,
/// Total bytes sent to the server
pub bytes_sent: u64,
/// Total bytes received from the server
pub bytes_received: u64,
/// Total number of connection errors encountered
pub connection_errors: u64,
/// Most recent error message, if any
pub last_error: Option<String>,
}
/// Connection manager for gRPC client connections
///
/// Manages connection pools, health monitoring, and statistics tracking
/// for all backend service connections. Provides automatic reconnection
/// and load balancing capabilities.
#[derive(Debug)]
pub struct ConnectionManager {
/// Connection configuration
config: ConnectionConfig,
/// Thread-safe connection statistics
stats: Arc<RwLock<ConnectionStats>>,
}
impl ConnectionManager {
/// Create a new connection manager with the given configuration
///
/// # Arguments
/// * `config` - Connection configuration parameters
///
/// # Returns
///
/// A new `ConnectionManager` instance ready to manage connections
pub fn new(config: ConnectionConfig) -> Self {
Self {
config,
stats: Arc::new(RwLock::new(ConnectionStats {
messages_sent: 0,
messages_received: 0,
bytes_sent: 0,
bytes_received: 0,
connection_errors: 0,
last_error: None,
})),
}
}
/// Get a reference to the connection configuration
pub fn config(&self) -> &ConnectionConfig {
&self.config
}
/// Establish a connection to the configured server
///
/// # Returns
///
/// Ok(()) if connection succeeds, Err with error message if it fails
pub async fn connect(&self) -> Result<(), String> {
Ok(())
}
/// Disconnect from the server and cleanup resources
///
/// # Returns
///
/// Ok(()) if disconnection succeeds, Err with error message if it fails
pub async fn disconnect(&self) -> Result<(), String> {
Ok(())
}
/// Get current connection statistics
///
/// # Returns
///
/// A clone of the current connection statistics
pub async fn get_stats(&self) -> ConnectionStats {
self.stats.read().await.clone()
}
/// Add a new service to the connection pool
///
/// # Arguments
/// * `service_name` - Unique identifier for the service
///
/// * `config` - Connection configuration for the service
///
/// # Returns
///
/// Ok(()) if service added successfully, Err with error message if it fails
pub async fn add_service(
&self,
_service_name: String,
_config: ConnectionConfig,
) -> Result<(), String> {
Ok(())
}
/// Get statistics for all connections in the pool
///
/// # Returns
/// `HashMap` mapping service names to their connection statistics
pub async fn get_pool_stats(&self) -> std::collections::HashMap<String, Vec<ConnectionStats>> {
std::collections::HashMap::new()
}
/// Gracefully shutdown all connections and cleanup resources
///
/// This method ensures all active connections are properly closed
/// and resources are released before the manager is destroyed.
pub async fn shutdown(&self) {
// Shutdown implementation
}
}

View File

@@ -1,247 +0,0 @@
//! ML training service gRPC client
//!
//! Provides a gRPC client for communicating with the ML training service,
//! including model training, resource monitoring, and training job management.
use anyhow::{Context, Result as AnyhowResult};
use serde::{Deserialize, Serialize};
use tonic::transport::Channel;
/// Configuration for the ML training service gRPC client
///
/// Contains connection parameters and client behavior settings for
/// communicating with the ML training service.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MLTrainingClientConfig {
/// gRPC endpoint URL for the ML training service (MUST be https://)
pub endpoint: String,
/// Request timeout in milliseconds
pub timeout_ms: u64,
}
impl Default for MLTrainingClientConfig {
/// Create default ML training client configuration
///
/// Returns configuration with API Gateway HTTPS endpoint and 120-second timeout
/// (longer timeout due to potentially long-running ML operations).
///
/// # Security
///
/// Defaults to HTTPS (not HTTP) to enforce encrypted connections.
///
/// # Wave 71 Update
///
/// Endpoint changed to API Gateway (port 50050) instead of direct ML training service.
///
/// All requests now route through the API Gateway for centralized authentication.
fn default() -> Self {
Self {
endpoint: "https://localhost:50050".to_owned(), // API Gateway endpoint
timeout_ms: 120_000,
}
}
}
/// gRPC client for the ML training service
///
/// Manages connection to the ML training service and provides methods for
/// starting training jobs, monitoring progress, and managing model lifecycles.
#[derive(Debug)]
pub struct MLTrainingClient {
/// Client configuration
config: MLTrainingClientConfig,
/// Active gRPC channel (None if disconnected)
channel: Option<Channel>,
}
impl MLTrainingClient {
/// Create a new ML training client with the specified configuration
///
/// # Arguments
/// * `config` - Client configuration including endpoint and timeout settings
///
/// # Returns
///
/// New `MLTrainingClient` instance (not yet connected)
pub const fn new(config: MLTrainingClientConfig) -> Self {
Self {
config,
channel: None,
}
}
/// Validate URL scheme is HTTPS (rejects HTTP)
///
/// # Security
///
/// This enforces fail-closed behavior - only HTTPS connections are allowed.
///
/// Insecure HTTP connections are rejected with a clear error message.
fn validate_endpoint_security(endpoint: &str) -> AnyhowResult<()> {
if endpoint.starts_with("http://") {
anyhow::bail!(
"SECURITY ERROR: Insecure HTTP endpoint rejected: {}. TLS (https://) is required for all gRPC connections to protect sensitive ML model data.",
endpoint
);
}
if !endpoint.starts_with("https://") {
anyhow::bail!(
"Invalid endpoint scheme in URL: {}. Only HTTPS is supported (example: https://ml-training-service:50054).",
endpoint
);
}
Ok(())
}
/// Establish connection to the ML training service
///
/// Creates a gRPC channel to the configured ML training service endpoint.
///
/// Must be called before making any service requests.
///
/// # Returns
/// `Result<(), anyhow::Error>` - Ok if connection successful
///
/// # Errors
///
/// Returns error if:
/// - Endpoint uses insecure HTTP scheme (security validation failure)
///
/// - Unable to parse endpoint URL
/// - Unable to connect to the service endpoint
///
/// # Security
///
/// This method enforces TLS by rejecting any HTTP endpoints.
///
/// Use HTTPS endpoints only (e.g., <https://localhost:50054>).
pub async fn connect(&mut self) -> AnyhowResult<()> {
// SECURITY: Validate endpoint uses HTTPS before attempting connection
Self::validate_endpoint_security(&self.config.endpoint)
.context("ML training client endpoint security validation failed")?;
// Parse endpoint with proper error handling (no unwrap/panic)
let channel = Channel::from_shared(self.config.endpoint.clone())
.context("Failed to parse ML training service endpoint URL - check URL format")?
.connect()
.await
.context("Failed to establish connection to ML training service - check network and TLS configuration")?;
self.channel = Some(channel);
tracing::info!(
"\u{2705} ML training client connected securely via TLS to {}",
self.config.endpoint
);
Ok(())
}
/// Check if the client is currently connected to the ML training service
///
/// # Returns
/// `true` if connected, `false` if disconnected
pub const fn is_connected(&self) -> bool {
self.channel.is_some()
}
/// Shutdown the client and close the connection
///
/// Cleanly closes the gRPC channel and releases associated resources.
///
/// The client can be reconnected after shutdown by calling `connect()`.
pub async fn shutdown(&mut self) {
if self.channel.is_some() {
tracing::info!(
"Shutting down ML training client connection to {}",
self.config.endpoint
);
}
self.channel = None;
}
}
/// Resource monitoring event for ML training operations
///
/// Contains system resource utilization metrics during model training,
/// including CPU, memory, and optional GPU usage statistics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceMonitoringEvent {
/// CPU utilization as a percentage (0.0 to 100.0)
pub cpu_usage: f64,
/// Memory utilization as a percentage (0.0 to 100.0)
pub memory_usage: f64,
/// GPU utilization as a percentage (None if no GPU available)
pub gpu_usage: Option<f64>,
/// Unix timestamp when the metrics were recorded (nanoseconds)
pub timestamp: i64,
}
/// Training job context and status information
///
/// Provides metadata about an active or completed ML training job,
/// including identification, model information, and progress tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingJobContext {
/// Unique identifier for the training job
pub job_id: String,
/// Name of the model being trained
pub model_name: String,
/// Current status of the training job (e.g., "running", "completed", "failed")
pub status: String,
/// Training progress as a percentage (0.0 to 100.0)
pub progress: f64,
}
/// Training progress event with performance metrics
///
/// Contains detailed progress information from ML model training,
/// including loss metrics, accuracy measurements, and timing data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingProgressEvent {
/// Training job identifier this event belongs to
pub job_id: String,
/// Current training epoch number
pub epoch: u32,
/// Current loss value for the epoch
pub loss: f64,
/// Optional accuracy metric (if available for the model type)
pub accuracy: Option<f64>,
/// Unix timestamp when the progress was recorded (nanoseconds)
pub timestamp: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_uses_https() {
let config = MLTrainingClientConfig::default();
assert!(
config.endpoint.starts_with("https://"),
"Default config must use HTTPS"
);
}
#[test]
fn test_http_validation_rejects_insecure() {
let result = MLTrainingClient::validate_endpoint_security("http://localhost:50054");
assert!(result.is_err(), "HTTP endpoints should be rejected");
assert!(result.unwrap_err().to_string().contains("Insecure HTTP"));
}
#[test]
fn test_https_validation_accepts_secure() {
let result = MLTrainingClient::validate_endpoint_security("https://localhost:50054");
assert!(result.is_ok(), "HTTPS endpoints should be accepted");
}
#[test]
fn test_invalid_scheme_rejected() {
let result = MLTrainingClient::validate_endpoint_security("ws://localhost:50054");
assert!(result.is_err(), "Non-HTTPS schemes should be rejected");
}
}

View File

@@ -1,319 +0,0 @@
//! TLI gRPC client modules
//!
//! This module contains comprehensive gRPC client implementations for all
//! core trading system services with advanced features including:
//! - Connection pooling and health monitoring
//! - Real-time streaming support
//! - Automatic reconnection and circuit breakers
//! - Comprehensive error handling
//! - Metrics collection and alerting
pub mod backtesting_client;
pub mod connection_manager;
pub mod ml_training_client;
pub mod trading_client;
use anyhow::{Context, Result};
use tonic::transport::Channel;
/// Create a gRPC channel that auto-configures TLS for `https://` URLs.
///
/// Connects eagerly (awaits the TCP handshake).
pub async fn connect_channel(url: &str) -> Result<Channel> {
let mut endpoint = Channel::from_shared(url.to_owned()).context("Invalid gRPC endpoint URL")?;
if url.starts_with("https://") {
endpoint = endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
.context("Failed to configure TLS")?;
}
endpoint
.connect()
.await
.context("Failed to connect to API Gateway")
}
/// Create a lazily-connected gRPC channel with TLS auto-detection.
///
/// Does not attempt the TCP connection until the first RPC call.
pub fn connect_channel_lazy(url: &str) -> Result<Channel> {
let mut endpoint = Channel::from_shared(url.to_owned()).context("Invalid gRPC endpoint URL")?;
if url.starts_with("https://") {
endpoint = endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
.context("Failed to configure TLS")?;
}
Ok(endpoint.connect_lazy())
}
/// Service endpoints configuration
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ServiceEndpoints {
/// Trading engine endpoint
pub trading_engine: String,
/// Market data endpoint
pub market_data: String,
/// Backtesting service endpoint
pub backtesting_service: String,
/// ML training service endpoint
pub ml_training_service: String,
}
impl ServiceEndpoints {
/// Create default endpoints for local development
///
/// # Security
///
/// All endpoints use HTTPS (not HTTP) to enforce encrypted connections.
///
/// # Wave 71 Update
///
/// All endpoints now point to API Gateway (port 50050) instead of direct services.
///
/// The API Gateway handles routing to backend services based on gRPC service names.
pub fn localhost() -> Self {
Self {
trading_engine: "https://localhost:50050".to_owned(), // API Gateway
market_data: "https://localhost:50050".to_owned(), // API Gateway
backtesting_service: "https://localhost:50050".to_owned(), // API Gateway
ml_training_service: "https://localhost:50050".to_owned(), // API Gateway
}
}
}
/// Client factory for creating and managing all service clients
#[derive(Debug)]
pub struct ClientFactory {
/// Connection manager shared across all clients
connection_manager: std::sync::Arc<connection_manager::ConnectionManager>,
}
impl ClientFactory {
/// Create a new client factory
pub fn new(connection_config: connection_manager::ConnectionConfig) -> Self {
let connection_manager = std::sync::Arc::new(connection_manager::ConnectionManager::new(
connection_config,
));
Self {
connection_manager,
}
}
/// Create a trading client
pub const fn create_trading_client(
&self,
config: trading_client::TradingClientConfig,
) -> trading_client::TradingClient {
trading_client::TradingClient::new(config)
}
/// Create a backtesting client
pub const fn create_backtesting_client(
&self,
config: backtesting_client::BacktestingClientConfig,
) -> backtesting_client::BacktestingClient {
backtesting_client::BacktestingClient::new(config)
}
/// Create an ML training client
pub const fn create_ml_training_client(
&self,
config: ml_training_client::MLTrainingClientConfig,
) -> ml_training_client::MLTrainingClient {
ml_training_client::MLTrainingClient::new(config)
}
/// Add a service connection to the pool
pub async fn add_service(
&self,
service_name: String,
config: connection_manager::ConnectionConfig,
) -> crate::error::TliResult<()> {
self.connection_manager
.add_service(service_name, config)
.await
.map_err(crate::error::TliError::Connection)
}
/// Get connection statistics for all services
pub async fn get_connection_stats(
&self,
) -> std::collections::HashMap<String, Vec<connection_manager::ConnectionStats>> {
self.connection_manager.get_pool_stats().await
}
/// Shutdown all connections and clients
pub async fn shutdown(&self) {
self.connection_manager.shutdown().await;
}
}
/// Convenience builder for creating a complete TLI client setup
#[derive(Debug)]
pub struct TliClientBuilder {
/// Connection configuration
connection_config: connection_manager::ConnectionConfig,
/// Service endpoints
service_endpoints: std::collections::HashMap<String, String>,
/// Client configurations
trading_config: Option<trading_client::TradingClientConfig>,
backtesting_config: Option<backtesting_client::BacktestingClientConfig>,
ml_training_config: Option<ml_training_client::MLTrainingClientConfig>,
}
impl Default for TliClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl TliClientBuilder {
/// Create a new builder
pub fn new() -> Self {
Self {
connection_config: connection_manager::ConnectionConfig::default(),
service_endpoints: std::collections::HashMap::new(),
trading_config: None,
backtesting_config: None,
ml_training_config: None,
}
}
/// Set connection configuration
pub fn with_connection_config(mut self, config: connection_manager::ConnectionConfig) -> Self {
self.connection_config = config;
self
}
/// Add a service endpoint
pub fn with_service_endpoint(mut self, service_name: String, endpoint: String) -> Self {
self.service_endpoints.insert(service_name, endpoint);
self
}
/// Set trading client configuration
pub fn with_trading_config(mut self, config: trading_client::TradingClientConfig) -> Self {
self.trading_config = Some(config);
self
}
/// Set backtesting client configuration
pub fn with_backtesting_config(
mut self,
config: backtesting_client::BacktestingClientConfig,
) -> Self {
self.backtesting_config = Some(config);
self
}
/// Set ML training client configuration
pub fn with_ml_training_config(
mut self,
config: ml_training_client::MLTrainingClientConfig,
) -> Self {
self.ml_training_config = Some(config);
self
}
/// Build the complete TLI client setup
pub async fn build(self) -> crate::error::TliResult<TliClientSuite> {
let factory = ClientFactory::new(self.connection_config.clone());
// Add service connections
for (service_name, endpoint) in self.service_endpoints {
let mut service_config = self.connection_config.clone();
service_config.server_url = endpoint;
factory.add_service(service_name, service_config).await?;
}
// Create clients
let trading_client = self
.trading_config
.map(|config| factory.create_trading_client(config));
let backtesting_client = self
.backtesting_config
.map(|config| factory.create_backtesting_client(config));
let ml_training_client = self
.ml_training_config
.map(|config| factory.create_ml_training_client(config));
Ok(TliClientSuite {
factory,
trading_client,
backtesting_client,
ml_training_client,
})
}
}
/// Complete TLI client suite with all service clients
#[derive(Debug)]
pub struct TliClientSuite {
/// Client factory
pub factory: ClientFactory,
/// Trading client (includes all operations: trading, risk, monitoring, config, system status)
pub trading_client: Option<trading_client::TradingClient>,
/// Backtesting client
pub backtesting_client: Option<backtesting_client::BacktestingClient>,
/// ML training client
pub ml_training_client: Option<ml_training_client::MLTrainingClient>,
}
impl TliClientSuite {
/// Get connection statistics for all services
pub async fn get_connection_stats(
&self,
) -> std::collections::HashMap<String, Vec<connection_manager::ConnectionStats>> {
self.factory.get_connection_stats().await
}
/// Shutdown all clients and connections
pub async fn shutdown(self) {
if let Some(mut client) = self.trading_client {
client.shutdown().await;
}
if let Some(mut client) = self.backtesting_client {
client.shutdown().await;
}
if let Some(mut client) = self.ml_training_client {
client.shutdown().await;
}
self.factory.shutdown().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_factory_creation() {
let config = connection_manager::ConnectionConfig::default();
let factory = ClientFactory::new(config);
// Test that factory can create clients
let trading_config = trading_client::TradingClientConfig::default();
let _trading_client = factory.create_trading_client(trading_config);
}
#[test]
fn test_builder_pattern() {
let builder = TliClientBuilder::new()
.with_service_endpoint(
"trading_service".to_owned(),
"http://localhost:50051".to_owned(),
)
.with_trading_config(trading_client::TradingClientConfig::default())
.with_backtesting_config(backtesting_client::BacktestingClientConfig::default())
.with_ml_training_config(ml_training_client::MLTrainingClientConfig::default());
// Builder should have the configuration set
assert!(builder.trading_config.is_some());
assert!(builder.backtesting_config.is_some());
assert!(builder.ml_training_config.is_some());
assert!(builder.service_endpoints.contains_key("trading_service"));
}
}

View File

@@ -1,196 +0,0 @@
//! Trading service gRPC client
//!
//! Provides a gRPC client for communicating with the trading service,
//! including order management, position tracking, and system monitoring.
use anyhow::{Context, Result as AnyhowResult};
use serde::{Deserialize, Serialize};
use tonic::transport::Channel;
/// Configuration for the trading service gRPC client
///
/// Contains connection parameters and client behavior settings for
/// communicating with the trading service.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradingClientConfig {
/// gRPC endpoint URL for the trading service (MUST be https://)
pub endpoint: String,
/// Request timeout in milliseconds
pub timeout_ms: u64,
}
impl Default for TradingClientConfig {
/// Create default trading client configuration
///
/// Returns configuration with API Gateway HTTPS endpoint and 30-second timeout.
///
/// # Security
///
/// Defaults to HTTPS (not HTTP) to enforce encrypted connections.
///
/// # Wave 71 Update
///
/// Endpoint changed to API Gateway (port 50050) instead of direct trading service.
///
/// All requests now route through the API Gateway for centralized authentication.
fn default() -> Self {
Self {
endpoint: "https://localhost:50050".to_owned(), // API Gateway endpoint
timeout_ms: 30_000,
}
}
}
/// gRPC client for the trading service
///
/// Manages connection to the trading service and provides methods for
/// order submission, position queries, and system status monitoring.
#[derive(Debug)]
pub struct TradingClient {
/// Client configuration
config: TradingClientConfig,
/// Active gRPC channel (None if disconnected)
channel: Option<Channel>,
}
impl TradingClient {
/// Create a new trading client with the specified configuration
///
/// # Arguments
/// * `config` - Client configuration including endpoint and timeout settings
///
/// # Returns
///
/// New `TradingClient` instance (not yet connected)
pub const fn new(config: TradingClientConfig) -> Self {
Self {
config,
channel: None,
}
}
/// Validate URL scheme is HTTPS (rejects HTTP)
///
/// # Security
///
/// This enforces fail-closed behavior - only HTTPS connections are allowed.
///
/// Insecure HTTP connections are rejected with a clear error message.
fn validate_endpoint_security(endpoint: &str) -> AnyhowResult<()> {
if endpoint.starts_with("http://") {
anyhow::bail!(
"SECURITY ERROR: Insecure HTTP endpoint rejected: {}. TLS (https://) is required for all gRPC connections to protect sensitive trading data.",
endpoint
);
}
if !endpoint.starts_with("https://") {
anyhow::bail!(
"Invalid endpoint scheme in URL: {}. Only HTTPS is supported (example: https://trading-service:50051).",
endpoint
);
}
Ok(())
}
/// Establish connection to the trading service
///
/// Creates a gRPC channel to the configured trading service endpoint.
///
/// Must be called before making any service requests.
///
/// # Returns
/// `Result<(), anyhow::Error>` - Ok if connection successful
///
/// # Errors
///
/// Returns error if:
/// - Endpoint uses insecure HTTP scheme (security validation failure)
///
/// - Unable to parse endpoint URL
/// - Unable to connect to the service endpoint
///
/// # Security
///
/// This method enforces TLS by rejecting any HTTP endpoints.
///
/// Use HTTPS endpoints only (e.g., <https://localhost:50051>).
pub async fn connect(&mut self) -> AnyhowResult<()> {
// SECURITY: Validate endpoint uses HTTPS before attempting connection
Self::validate_endpoint_security(&self.config.endpoint)
.context("Trading client endpoint security validation failed")?;
// Parse endpoint with proper error handling (no unwrap/panic)
let channel = Channel::from_shared(self.config.endpoint.clone())
.context("Failed to parse trading service endpoint URL - check URL format")?
.connect()
.await
.context("Failed to establish connection to trading service - check network and TLS configuration")?;
self.channel = Some(channel);
tracing::info!(
"\u{2705} Trading client connected securely via TLS to {}",
self.config.endpoint
);
Ok(())
}
/// Check if the client is currently connected to the trading service
///
/// # Returns
/// `true` if connected, `false` if disconnected
pub const fn is_connected(&self) -> bool {
self.channel.is_some()
}
/// Shutdown the client and close the connection
///
/// Cleanly closes the gRPC channel and releases associated resources.
///
/// The client can be reconnected after shutdown by calling `connect()`.
pub async fn shutdown(&mut self) {
if self.channel.is_some() {
tracing::info!(
"Shutting down trading client connection to {}",
self.config.endpoint
);
}
self.channel = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_uses_https() {
let config = TradingClientConfig::default();
assert!(
config.endpoint.starts_with("https://"),
"Default config must use HTTPS"
);
}
#[test]
fn test_http_validation_rejects_insecure() {
let result = TradingClient::validate_endpoint_security("http://localhost:50051");
assert!(result.is_err(), "HTTP endpoints should be rejected");
assert!(result.unwrap_err().to_string().contains("Insecure HTTP"));
}
#[test]
fn test_https_validation_accepts_secure() {
let result = TradingClient::validate_endpoint_security("https://localhost:50051");
assert!(result.is_ok(), "HTTPS endpoints should be accepted");
}
#[test]
fn test_invalid_scheme_rejected() {
let result = TradingClient::validate_endpoint_security("ftp://localhost:50051");
assert!(result.is_err(), "Non-HTTPS schemes should be rejected");
}
}

View File

@@ -1,480 +1,36 @@
//! TLI Agent Commands
//!
//! Command-line interface for Trading Agent operations.
//! Connects to Trading Agent Service via API Gateway for portfolio allocation,
//! asset selection, and strategy coordination.
//!
//! # Commands
//! - `allocate-portfolio` - Allocate capital across selected assets
//!
//! # Architecture
//! - Pure client implementation (connects ONLY to API Gateway at port 50051)
//! - gRPC communication with `TradingAgentService` via API Gateway proxy
//! - No direct service dependencies (proper microservice architecture)
//! `fxt agent` -- trading agent control.
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use colored::Colorize;
// Channel creation via crate::client::connect_channel
use tonic::Request;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tonic::metadata::MetadataValue;
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
use crate::proto::trading_agent::{
trading_agent_service_client::TradingAgentServiceClient, AllocatePortfolioRequest,
AllocationStrategy, AllocationType, GetSelectedAssetsRequest, RiskConstraints,
};
/// Agent command arguments
#[derive(Args, Debug)]
pub struct AgentArgs {
#[derive(Parser, Debug)]
pub struct AgentCommand {
#[command(subcommand)]
command: AgentCommand,
action: AgentAction,
}
/// Agent subcommands
#[derive(Subcommand, Debug, Clone)]
pub enum AgentCommand {
/// Allocate portfolio capital across assets
#[clap(
long_about = "Allocate capital across selected assets using various strategies.\n\n\
Strategies:\n\
- equal-weight: 1/N allocation across all assets\n\
- risk-parity: Equal risk contribution per asset\n\
- ml-optimized: ML-based portfolio optimization\n\
- mean-variance: Mean-variance optimization (Markowitz)\n\
- kelly: Kelly criterion allocation\n\n\
Examples:\n\
tli agent allocate-portfolio --selection-id abc-123 --total-capital 100000\n\
tli agent allocate-portfolio --selection-id abc-123 --total-capital 100000 --strategy risk-parity"
)]
AllocatePortfolio(AllocatePortfolioArgs),
#[derive(Subcommand, Debug)]
enum AgentAction {
/// Start the trading agent
Start,
/// Stop the trading agent
Stop,
/// Show agent status (strategy, positions, signals)
Status,
/// Show/update agent configuration
Config {
/// Configuration key to get/set
key: Option<String>,
/// Value to set (omit to read current value)
value: Option<String>,
},
}
/// Portfolio allocation arguments (public for testing)
#[derive(Debug, Args, Clone)]
pub struct AllocatePortfolioArgs {
/// Asset selection ID from previous `SelectAssets` call
#[arg(long, required = true)]
pub selection_id: String,
/// Total capital to allocate (USD)
#[arg(long, required = true)]
pub total_capital: f64,
/// Allocation strategy
#[arg(long, default_value = "ml-optimized")]
pub strategy: String,
/// Maximum position size (percentage of portfolio, 0.0-1.0)
#[arg(long, default_value = "0.20")]
pub max_position_size: f64,
/// Minimum position size (percentage of portfolio, 0.0-1.0)
#[arg(long, default_value = "0.05")]
pub min_position_size: f64,
}
impl AgentArgs {
/// Execute agent command
///
/// Routes to appropriate subcommand handler.
/// All commands connect to API Gateway (<http://localhost:50051>).
pub async fn execute(&self, api_gateway_url: &str, jwt_token: &str) -> Result<()> {
match &self.command {
AgentCommand::AllocatePortfolio(args) => {
handle_allocate_portfolio(args.clone(), api_gateway_url, jwt_token).await
},
}
}
}
/// Parse allocation strategy string to `AllocationType` enum
fn parse_allocation_strategy(strategy: &str) -> Result<AllocationType> {
match strategy.to_lowercase().as_str() {
"equal-weight" | "equalweight" => Ok(AllocationType::EqualWeight),
"risk-parity" | "riskparity" => Ok(AllocationType::RiskParity),
"ml-optimized" | "mloptimized" => Ok(AllocationType::MlOptimized),
"mean-variance" | "meanvariance" => Ok(AllocationType::MeanVariance),
"kelly" => Ok(AllocationType::Kelly),
_ => anyhow::bail!(
"Unknown allocation strategy: {}. Valid options: equal-weight, risk-parity, ml-optimized, mean-variance, kelly",
strategy
),
}
}
/// Validate portfolio allocation constraints
fn validate_constraints(args: &AllocatePortfolioArgs) -> Result<()> {
// Validate total capital is positive
if args.total_capital <= 0.0 {
anyhow::bail!(
"Total capital must be positive, got: {}",
args.total_capital
);
}
// Validate position size constraints (0 < min < max < 1.0)
if args.min_position_size <= 0.0 || args.min_position_size >= 1.0 {
anyhow::bail!(
"Minimum position size must be between 0.0 and 1.0, got: {}",
args.min_position_size
);
}
if args.max_position_size <= 0.0 || args.max_position_size > 1.0 {
anyhow::bail!(
"Maximum position size must be between 0.0 and 1.0, got: {}",
args.max_position_size
);
}
if args.min_position_size >= args.max_position_size {
anyhow::bail!(
"Minimum position size ({}) must be less than maximum position size ({})",
args.min_position_size,
args.max_position_size
);
}
Ok(())
}
/// Handle allocate-portfolio command (public for testing)
///
/// # Arguments
/// * `args` - Portfolio allocation arguments
/// * `api_gateway_url` - API Gateway URL
/// * `jwt_token` - JWT authentication token
///
/// # Production Implementation
/// Connects to Trading Agent Service via API Gateway and requests portfolio allocation.
pub async fn handle_allocate_portfolio(
args: AllocatePortfolioArgs,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
// Validate constraints
validate_constraints(&args).context("Invalid portfolio allocation constraints")?;
// Parse allocation strategy
let allocation_type =
parse_allocation_strategy(&args.strategy).context("Failed to parse allocation strategy")?;
println!("{}", "\u{1f4ca} Allocating Portfolio...".bold());
println!(
"Strategy: {} | Total Capital: ${:.2}",
args.strategy.bright_magenta(),
args.total_capital
);
println!(
"Position Size Range: {:.1}% - {:.1}%",
args.min_position_size * 100.0,
args.max_position_size * 100.0
);
println!();
// Connect to API Gateway
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = TradingAgentServiceClient::new(channel);
// Fetch real assets from selection
let mut get_assets_request = Request::new(GetSelectedAssetsRequest {
universe_id: Some(args.selection_id.clone()),
});
let get_token = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
get_assets_request
.metadata_mut()
.insert("authorization", get_token);
let assets_response = client
.get_selected_assets(get_assets_request)
.await
.context("Failed to fetch selected assets")?
.into_inner();
let assets = assets_response.assets;
// Create allocation request
let request = AllocatePortfolioRequest {
assets,
strategy: Some(AllocationStrategy {
allocation_type: allocation_type as i32,
parameters: std::collections::HashMap::new(),
}),
risk_constraints: Some(RiskConstraints {
max_position_size_pct: args.max_position_size,
max_sector_exposure_pct: 0.40,
max_volatility: 0.25,
max_var_95: 0.05,
max_leverage: 1.0,
}),
total_capital: args.total_capital,
};
// Add JWT token to metadata
let mut grpc_request = Request::new(request);
if let Ok(auth_value) = format!("Bearer {}", jwt_token).parse() {
grpc_request.metadata_mut().insert("authorization", auth_value);
}
// Call AllocatePortfolio RPC
let response = client
.allocate_portfolio(grpc_request)
.await
.context("Failed to allocate portfolio")?
.into_inner();
// Display allocation results
println!(
"{}",
format!("Portfolio Allocation (ID: {})", response.allocation_id)
.green()
.bold()
);
println!(
"Strategy: {} | Total Capital: ${}",
args.strategy, args.total_capital
);
println!();
// Display allocation table
println!("\u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}");
println!(
"\u{2502} {:<10} \u{2502} {:<8} \u{2502} {:<12} \u{2502} {:<15} \u{2502}",
"Symbol".bold(),
"Weight".bold(),
"Capital".bold(),
"Position Size".bold()
);
println!("\u{251c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2524}");
for allocation in &response.allocations {
println!(
"\u{2502} {:<10} \u{2502} {:>7.1}% \u{2502} ${:>10.2} \u{2502} {:>12.0} contracts\u{2502}",
allocation.symbol,
allocation.target_weight * 100.0,
allocation.target_capital,
allocation.target_quantity
);
}
println!("\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}");
println!();
// Display risk metrics
if let Some(metrics) = response.metrics {
println!("{}", "Risk Metrics:".bold());
println!(
" Portfolio Volatility: {:.1}%",
metrics.portfolio_volatility * 100.0
);
println!(" Sharpe Ratio: {:.2}", metrics.portfolio_sharpe);
println!(
" Max Drawdown: {:.1}%",
metrics.max_drawdown_estimate * 100.0
);
println!(" VaR (95%): {:.1}%", metrics.var_95 * 100.0);
}
Ok(())
}
/// Execute agent command (public interface for main.rs)
///
/// # Arguments
/// * `args` - Agent command arguments
/// * `api_gateway_url` - API Gateway URL
/// * `jwt_token` - JWT authentication token
pub async fn execute_agent_command(
args: AgentArgs,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
args.execute(api_gateway_url, jwt_token).await
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_parse_allocation_strategy_equal_weight() {
let result = parse_allocation_strategy("equal-weight");
assert!(result.is_ok());
assert_eq!(result.unwrap() as i32, AllocationType::EqualWeight as i32);
}
#[test]
fn test_parse_allocation_strategy_risk_parity() {
let result = parse_allocation_strategy("risk-parity");
assert!(result.is_ok());
assert_eq!(result.unwrap() as i32, AllocationType::RiskParity as i32);
}
#[test]
fn test_parse_allocation_strategy_ml_optimized() {
let result = parse_allocation_strategy("ml-optimized");
assert!(result.is_ok());
assert_eq!(result.unwrap() as i32, AllocationType::MlOptimized as i32);
}
#[test]
fn test_parse_allocation_strategy_mean_variance() {
let result = parse_allocation_strategy("mean-variance");
assert!(result.is_ok());
assert_eq!(result.unwrap() as i32, AllocationType::MeanVariance as i32);
}
#[test]
fn test_parse_allocation_strategy_kelly() {
let result = parse_allocation_strategy("kelly");
assert!(result.is_ok());
assert_eq!(result.unwrap() as i32, AllocationType::Kelly as i32);
}
#[test]
fn test_parse_allocation_strategy_case_insensitive() {
parse_allocation_strategy("EQUAL-WEIGHT").unwrap();
parse_allocation_strategy("Risk-Parity").unwrap();
parse_allocation_strategy("ML-OPTIMIZED").unwrap();
}
#[test]
fn test_parse_allocation_strategy_invalid() {
let result = parse_allocation_strategy("invalid-strategy");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unknown allocation strategy"));
}
#[test]
fn test_validate_constraints_valid() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.20,
min_position_size: 0.05,
};
let result = validate_constraints(&args);
result.unwrap();
}
#[test]
fn test_validate_constraints_negative_capital() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: -1000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.20,
min_position_size: 0.05,
};
let result = validate_constraints(&args);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("positive"));
}
#[test]
fn test_validate_constraints_zero_capital() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 0.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.20,
min_position_size: 0.05,
};
let result = validate_constraints(&args);
assert!(result.is_err());
}
#[test]
fn test_validate_constraints_min_size_too_small() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.20,
min_position_size: 0.0,
};
let result = validate_constraints(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Minimum position size"));
}
#[test]
fn test_validate_constraints_min_size_too_large() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.20,
min_position_size: 1.0,
};
let result = validate_constraints(&args);
assert!(result.is_err());
}
#[test]
fn test_validate_constraints_max_size_too_large() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 1.5,
min_position_size: 0.05,
};
let result = validate_constraints(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Maximum position size"));
}
#[test]
fn test_validate_constraints_min_greater_than_max() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.10,
min_position_size: 0.20,
};
let result = validate_constraints(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must be less than maximum"));
}
#[test]
fn test_validate_constraints_min_equals_max() {
let args = AllocatePortfolioArgs {
selection_id: "test-123".to_owned(),
total_capital: 100000.0,
strategy: "ml-optimized".to_owned(),
max_position_size: 0.15,
min_position_size: 0.15,
};
let result = validate_constraints(&args);
assert!(result.is_err());
impl AgentCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("agent command not yet implemented")
}
}

View File

@@ -1,322 +1,33 @@
//! Authentication Commands
//!
//! CLI commands for user authentication operations:
//! - Login with username/password (interactive password prompt)
//! - Logout (clear stored credentials)
//! - Status (show authentication status)
//! - Refresh (manually refresh access token)
//! `fxt auth` -- authentication and session management.
use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::auth::{
token_manager::{AuthTokenManager, FileTokenStorage, TokenStorage},
LoginClient,
};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
/// Authentication subcommands
#[derive(Subcommand, Debug, Clone)]
pub enum AuthCommand {
/// Login to Foxhunt system with username/password
#[derive(Parser, Debug)]
pub struct AuthCommand {
#[command(subcommand)]
action: AuthAction,
}
#[derive(Subcommand, Debug)]
enum AuthAction {
/// Login with username and password
Login {
/// Username for authentication
#[clap(short, long)]
/// Username
#[arg(long)]
username: Option<String>,
/// Password (if not provided, will prompt securely)
#[clap(short, long)]
password: Option<String>,
/// API Gateway URL
#[clap(
long,
env = "API_GATEWAY_URL",
default_value = "https://api.fxhnt.ai"
)]
api_gateway_url: String,
},
/// Logout and clear stored credentials
/// Logout and clear stored tokens
Logout,
/// Show current authentication status
Status,
/// Refresh access token using refresh token
Refresh {
/// API Gateway URL
#[clap(
long,
env = "API_GATEWAY_URL",
default_value = "https://api.fxhnt.ai"
)]
api_gateway_url: String,
},
}
/// Execute authentication command
pub async fn execute_auth_command(command: AuthCommand) -> Result<()> {
match command {
AuthCommand::Login {
username,
password,
api_gateway_url,
} => execute_login(username, password, &api_gateway_url).await,
AuthCommand::Logout => execute_logout().await,
AuthCommand::Status => execute_status().await,
AuthCommand::Refresh { api_gateway_url } => execute_refresh(&api_gateway_url).await,
impl AuthCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("auth command not yet implemented")
}
}
/// Execute login command
async fn execute_login(
username: Option<String>,
password: Option<String>,
api_gateway_url: &str,
) -> Result<()> {
println!("\n{}", "=== Foxhunt TLI Authentication ===".cyan().bold());
println!();
// If username or password not provided, use interactive flow
if username.is_none() || password.is_none() {
return execute_interactive_login(api_gateway_url).await;
}
// Non-interactive login - both are Some() since we checked is_none() above
let resolved_username = match username {
Some(u) => u,
None => return execute_interactive_login(api_gateway_url).await,
};
let resolved_password = match password {
Some(p) => p,
None => return execute_interactive_login(api_gateway_url).await,
};
println!("{}", "Connecting to API Gateway...".cyan());
// Connect to API Gateway
let channel = crate::client::connect_channel(api_gateway_url).await?;
// Create auth components
let storage = FileTokenStorage::new().context("Failed to initialize token storage")?;
let auth_manager = AuthTokenManager::new(storage);
let login_client = LoginClient::new(channel);
println!("{}", "Authenticating...".cyan());
// Use LoginClient for non-interactive login with provided credentials
login_client
.login_with_credentials(&resolved_username, &resolved_password, &auth_manager)
.await
.context("Authentication failed")?;
println!();
println!("{}", "\u{2713} Login successful!".green().bold());
println!("{}", format!(" User: {}", resolved_username).green());
println!();
Ok(())
}
/// Execute interactive login (prompts for credentials)
async fn execute_interactive_login(api_gateway_url: &str) -> Result<()> {
use std::io;
// Prompt for username
print!("{}", "Username: ".cyan().bold());
io::stdout().flush()?;
let mut raw_username = String::new();
io::stdin()
.read_line(&mut raw_username)
.context("Failed to read username")?;
let username = raw_username.trim().to_owned();
if username.is_empty() {
anyhow::bail!("Username cannot be empty");
}
// Prompt for password (hidden input)
print!("{}", "Password: ".cyan().bold());
io::stdout().flush()?;
let password = rpassword::read_password().context("Failed to read password")?;
if password.is_empty() {
anyhow::bail!("Password cannot be empty");
}
// Connect to API Gateway
println!();
println!("{}", "Connecting to API Gateway...".cyan());
let channel = crate::client::connect_channel(api_gateway_url).await?;
// Create auth components
let storage = FileTokenStorage::new().context("Failed to initialize token storage")?;
let auth_manager = AuthTokenManager::new(storage);
let login_client = LoginClient::new(channel);
println!("{}", "Authenticating...".cyan());
// Use LoginClient's interactive login (handles MFA if needed)
login_client
.interactive_login(&auth_manager)
.await
.context("Authentication failed")?;
println!("{}", format!(" User: {}", username).green());
println!();
Ok(())
}
/// Execute logout command
async fn execute_logout() -> Result<()> {
let storage = FileTokenStorage::new().context("Failed to initialize token storage")?;
// Clear both access and refresh tokens from storage
storage
.clear_access_token()
.await
.context("Failed to clear access token")?;
storage
.remove_refresh_token()
.await
.context("Failed to clear refresh token")?;
println!("{}", "\u{2713} Logged out successfully".green().bold());
println!(" All tokens cleared from storage");
println!(" Run: {} to login again", "tli auth login".bright_cyan());
Ok(())
}
/// JWT claims structure for token parsing
#[derive(Debug, serde::Deserialize)]
struct JwtClaims {
exp: u64,
sub: String,
}
/// Parse JWT claims without signature verification
fn parse_jwt_claims(token: &str) -> Result<JwtClaims> {
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.validate_exp = false;
let token_data = decode::<JwtClaims>(token, &DecodingKey::from_secret(b"dummy"), &validation)?;
Ok(token_data.claims)
}
/// Execute status command
async fn execute_status() -> Result<()> {
println!("{}", "=== Authentication Status ===".cyan().bold());
println!();
// Read tokens directly from file storage
let storage = FileTokenStorage::new().context("Failed to initialize token storage")?;
// Check for access token in storage
if let Some(token) = storage.get_access_token().await? {
println!("{}", "\u{2713} Authenticated".green().bold());
println!(
"{}",
format!(" Token: {}...", token.get(..token.len().min(30)).unwrap_or(&token)).green()
);
// Try to parse token and show expiry
if let Ok(claims) = parse_jwt_claims(&token) {
// Display username from JWT subject
println!("{}", format!(" User: {}", claims.sub).green());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("Failed to get current time")?
.as_secs();
if claims.exp > now {
let remaining = claims.exp - now;
let minutes = remaining / 60;
let seconds = remaining % 60;
if remaining > 60 {
println!(
"{}",
format!(" Expires in: {} minutes, {} seconds", minutes, seconds).cyan()
);
} else {
println!(
"{}",
format!(" Expires in: {} seconds (refresh recommended)", seconds).yellow()
);
}
} else {
println!("{}", " Status: EXPIRED".red());
}
}
// Check for refresh token availability
match storage.get_refresh_token().await {
Ok(Some(_)) => println!("{}", " Refresh token: Available".green()),
Ok(None) => println!("{}", " Refresh token: Not available".yellow()),
Err(_) => println!("{}", " Refresh token: Not available".yellow()),
}
} else {
println!("{}", "\u{2717} Not authenticated".red().bold());
println!("{}", " Run 'tli auth login' to authenticate".yellow());
}
println!();
Ok(())
}
/// Execute refresh command
async fn execute_refresh(api_gateway_url: &str) -> Result<()> {
println!("{}", "Refreshing tokens...".cyan());
// Connect to API Gateway
let channel = crate::client::connect_channel(api_gateway_url).await?;
// Create auth components
let storage = FileTokenStorage::new().context("Failed to initialize token storage")?;
let auth_manager = AuthTokenManager::new(storage);
let login_client = LoginClient::new(channel);
// Check if refresh token exists
if auth_manager.get_refresh_token().await?.is_none() {
anyhow::bail!("No refresh token available. Please login first with 'tli auth login'");
}
// Attempt refresh
login_client
.refresh_tokens(&auth_manager)
.await
.context("Token refresh failed")?;
println!(
"{}",
"\u{2713} Tokens refreshed successfully".green().bold()
);
// Show new expiry
if let Some(time_remaining) = auth_manager.time_until_expiry().await {
let minutes = time_remaining.as_secs() / 60;
let seconds = time_remaining.as_secs() % 60;
println!(
"{}",
format!(
" New token expires in: {} minutes, {} seconds",
minutes, seconds
)
.cyan()
);
}
Ok(())
}

View File

@@ -0,0 +1,48 @@
//! `fxt backtest` -- backtesting operations.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct BacktestCommand {
#[command(subcommand)]
action: BacktestAction,
}
#[derive(Subcommand, Debug)]
enum BacktestAction {
/// Run a backtest
Run {
/// Strategy name or model ID
#[arg(long)]
strategy: String,
/// Symbol to backtest
#[arg(long)]
symbol: String,
/// Start date (YYYY-MM-DD)
#[arg(long)]
from: String,
/// End date (YYYY-MM-DD)
#[arg(long)]
to: String,
},
/// Check backtest status
Status {
/// Backtest job ID
job_id: String,
},
/// Show backtest results
Results {
/// Backtest job ID
job_id: String,
},
}
impl BacktestCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("backtest command not yet implemented")
}
}

View File

@@ -1,428 +0,0 @@
//! ML Backtesting Commands for TLI
//!
//! Command-line interface for ML-powered backtesting operations.
//! Connects to `BacktestingService` gRPC endpoint.
use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use colored::Colorize;
use tonic::Request;
use tracing::{debug, error};
use crate::proto::trading::{
backtesting_service_client::BacktestingServiceClient, BacktestStatus,
GetBacktestResultsRequest, GetBacktestStatusRequest, StartBacktestRequest,
};
/// ML Backtesting command arguments
#[derive(Args, Debug)]
pub struct BacktestMlArgs {
/// Subcommand to execute
#[clap(subcommand)]
pub command: BacktestMlCommand,
/// API Gateway URL (override config)
#[clap(long, env = "API_GATEWAY_URL")]
pub api_gateway_url: Option<String>,
}
/// ML Backtesting subcommands
#[derive(Subcommand, Debug)]
pub enum BacktestMlCommand {
/// Run ML ensemble backtest
Run {
/// Symbol to backtest (e.g., ES.FUT, NQ.FUT)
#[arg(short, long)]
symbol: String,
/// Start date (YYYY-MM-DD)
#[arg(long)]
start: String,
/// End date (YYYY-MM-DD)
#[arg(long)]
end: String,
/// Initial capital
#[arg(short, long, default_value = "100000.0")]
capital: f64,
/// Confidence threshold (0.0-1.0)
#[arg(short = 't', long, default_value = "0.6")]
threshold: f64,
/// Use ensemble (all models) or single model
#[arg(long, default_value = "true")]
ensemble: bool,
/// Specific model name if not using ensemble (DQN, PPO, MAMBA2, TFT)
#[arg(long)]
model: Option<String>,
/// Compare with rule-based strategy
#[arg(long)]
compare: bool,
/// Description for this backtest run
#[arg(short, long)]
description: Option<String>,
},
/// Get status of running backtest
Status {
/// Backtest ID to check
#[arg(short, long)]
id: String,
},
/// Get results of completed backtest
Results {
/// Backtest ID to fetch results for
#[arg(short, long)]
id: String,
/// Include individual trades in output
#[arg(long)]
trades: bool,
},
}
/// Execute ML backtesting command
pub async fn execute_backtest_ml_command(args: BacktestMlArgs) -> Result<()> {
let gateway_url = args
.api_gateway_url
.unwrap_or_else(|| "http://localhost:50051".to_owned());
debug!("Connecting to API Gateway at: {}", gateway_url);
let mut client = BacktestingServiceClient::connect(gateway_url.clone())
.await
.context("Failed to connect to Backtesting Service")?;
match args.command {
BacktestMlCommand::Run {
symbol,
start,
end,
capital,
threshold,
ensemble,
model,
compare,
description,
} => {
run_ml_backtest(
&mut client,
symbol,
start,
end,
capital,
threshold,
ensemble,
model,
compare,
description,
)
.await
},
BacktestMlCommand::Status { id } => get_backtest_status(&mut client, id).await,
BacktestMlCommand::Results { id, trades } => {
get_backtest_results(&mut client, id, trades).await
},
}
}
/// Helper to convert date string to Unix nanos
fn date_to_unix_nanos(date_str: &str) -> Result<i64> {
let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.context("Invalid date format, use YYYY-MM-DD")?
.and_hms_opt(0, 0, 0)
.context("Failed to create datetime")?;
date.and_utc()
.timestamp_nanos_opt()
.context("Date out of range for nanosecond timestamp")
}
/// Run ML backtest
async fn run_ml_backtest(
client: &mut BacktestingServiceClient<tonic::transport::Channel>,
symbol: String,
start: String,
end: String,
capital: f64,
threshold: f64,
ensemble: bool,
model: Option<String>,
compare: bool,
description: Option<String>,
) -> Result<()> {
println!("{}", "\u{1f680} Starting ML Backtest".bold().green());
println!("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}");
let start_nanos = date_to_unix_nanos(&start)?;
let end_nanos = date_to_unix_nanos(&end)?;
// Build parameters
let mut parameters = vec![
("confidence_threshold".to_owned(), threshold.to_string()),
("use_ensemble".to_owned(), ensemble.to_string()),
];
if let Some(ref model_name) = model {
parameters.push(("model_name".to_owned(), model_name.clone()));
}
// Run ML backtest
let ml_request = Request::new(StartBacktestRequest {
strategy_name: "MLEnsemble".to_owned(),
symbols: vec![symbol.clone()],
start_date_unix_nanos: start_nanos,
end_date_unix_nanos: end_nanos,
initial_capital: capital,
parameters: parameters.into_iter().collect(),
save_results: true,
description: description
.clone()
.unwrap_or_else(|| "ML backtest via TLI".to_owned()),
});
let ml_response = client
.start_backtest(ml_request)
.await
.context("Failed to start ML backtest")?;
let ml_result = ml_response.into_inner();
if !ml_result.success {
error!("ML backtest failed to start: {}", ml_result.message);
return Err(anyhow::anyhow!(
"Failed to start backtest: {}",
ml_result.message
));
}
let ml_id = ml_result.backtest_id.clone();
println!("\u{2705} ML Backtest started: {}", ml_id.bright_cyan());
println!(" Symbol: {}", symbol.bright_yellow());
println!(" Period: {} to {}", start, end);
println!(" Capital: ${:.2}", capital);
println!(" Threshold: {:.1}%", threshold * 100.0);
println!(
" Mode: {}",
if ensemble {
"Ensemble (All Models)".bright_green()
} else {
format!(
"Single Model ({})",
model.unwrap_or_else(|| "DQN".to_owned())
)
.bright_blue()
}
);
// If compare flag is set, also run rule-based backtest
if compare {
println!(
"\n{}",
"\u{1f4ca} Running comparison backtest...".bold().cyan()
);
let rule_request = Request::new(StartBacktestRequest {
strategy_name: "MovingAverageCrossover".to_owned(),
symbols: vec![symbol.clone()],
start_date_unix_nanos: start_nanos,
end_date_unix_nanos: end_nanos,
initial_capital: capital,
parameters: vec![
("fast_period".to_owned(), "10".to_owned()),
("slow_period".to_owned(), "20".to_owned()),
]
.into_iter()
.collect(),
save_results: true,
description: "Rule-based comparison backtest".to_owned(),
});
let rule_response = client
.start_backtest(rule_request)
.await
.context("Failed to start comparison backtest")?;
let rule_result = rule_response.into_inner();
if rule_result.success {
println!(
"\u{2705} Comparison backtest started: {}",
rule_result.backtest_id.bright_cyan()
);
}
}
println!(
"\n\u{1f4a1} Use {} to check status",
format!("tli backtest ml status --id {}", ml_id).bright_yellow()
);
println!(
"\u{1f4a1} Use {} to get results",
format!("tli backtest ml results --id {}", ml_id).bright_yellow()
);
Ok(())
}
/// Get backtest status
async fn get_backtest_status(
client: &mut BacktestingServiceClient<tonic::transport::Channel>,
id: String,
) -> Result<()> {
let request = Request::new(GetBacktestStatusRequest {
backtest_id: id.clone(),
});
let response = client
.get_backtest_status(request)
.await
.context("Failed to get backtest status")?;
let status = response.into_inner();
println!("{}", "\u{1f4ca} Backtest Status".bold().green());
println!("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}");
println!("ID: {}", status.backtest_id.bright_cyan());
println!("Status: {}", format_backtest_status(status.status()));
println!("Progress: {:.1}%", status.progress_percentage);
println!("Current Date: {}", status.current_date);
println!("Trades Executed: {}", status.trades_executed);
println!("Current P&L: ${:.2}", status.current_pnl);
if let Some(error) = status.error_message {
println!("{}: {}", "Error".bright_red(), error);
}
Ok(())
}
/// Get backtest results
async fn get_backtest_results(
client: &mut BacktestingServiceClient<tonic::transport::Channel>,
id: String,
include_trades: bool,
) -> Result<()> {
let request = Request::new(GetBacktestResultsRequest {
backtest_id: id.clone(),
include_trades,
include_metrics: true,
});
let response = client
.get_backtest_results(request)
.await
.context("Failed to get backtest results")?;
let results = response.into_inner();
println!("{}", "\u{1f4c8} ML Backtest Results".bold().green());
println!("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}");
if let Some(metrics) = results.metrics {
println!("\n{}", "Performance Metrics:".bold());
println!(" Total Return: {:.2}%", metrics.total_return * 100.0);
println!(
" Annualized Return: {:.2}%",
metrics.annualized_return * 100.0
);
println!(" Sharpe Ratio: {:.2}", metrics.sharpe_ratio);
println!(" Sortino Ratio: {:.2}", metrics.sortino_ratio);
println!(" Max Drawdown: {:.2}%", metrics.max_drawdown * 100.0);
println!(" Calmar Ratio: {:.2}", metrics.calmar_ratio);
println!("\n{}", "Trade Statistics:".bold());
println!(" Total Trades: {}", metrics.total_trades);
println!(
" Winning Trades: {} ({:.1}%)",
metrics.winning_trades,
metrics.win_rate * 100.0
);
println!(" Losing Trades: {}", metrics.losing_trades);
println!(" Profit Factor: {:.2}", metrics.profit_factor);
println!(" Average Win: ${:.2}", metrics.avg_win);
println!(" Average Loss: ${:.2}", metrics.avg_loss);
println!(" Largest Win: ${:.2}", metrics.largest_win);
println!(" Largest Loss: ${:.2}", metrics.largest_loss);
// Highlight target achievements
println!("\n{}", "Target Metrics:".bold());
if metrics.sharpe_ratio > 1.5 {
println!(" \u{2705} Sharpe Ratio > 1.5 (ACHIEVED)");
} else {
println!(
" \u{26a0}\u{fe0f} Sharpe Ratio: {:.2} (target: >1.5)",
metrics.sharpe_ratio
);
}
if metrics.win_rate > 0.55 {
println!(" \u{2705} Win Rate > 55% (ACHIEVED)");
} else {
println!(
" \u{26a0}\u{fe0f} Win Rate: {:.1}% (target: >55%)",
metrics.win_rate * 100.0
);
}
if metrics.max_drawdown < 0.20 {
println!(" \u{2705} Max Drawdown < 20% (ACHIEVED)");
} else {
println!(
" \u{26a0}\u{fe0f} Max Drawdown: {:.1}% (target: <20%)",
metrics.max_drawdown * 100.0
);
}
} else {
println!("{}", "No metrics available".bright_red());
}
if include_trades && !results.trades.is_empty() {
println!(
"\n{}",
format!("Recent Trades ({} total):", results.trades.len()).bold()
);
for (i, trade) in results.trades.iter().take(10).enumerate() {
println!(
" {}. {} {} @ ${:.2} \u{2192} ${:.2} = {}",
i + 1,
trade.symbol,
format_order_side(trade.side),
trade.entry_price,
trade.exit_price,
if trade.pnl >= 0.0 {
format!("+${:.2}", trade.pnl).bright_green()
} else {
format!("-${:.2}", trade.pnl.abs()).bright_red()
}
);
}
if results.trades.len() > 10 {
println!(" ... and {} more trades", results.trades.len() - 10);
}
}
Ok(())
}
/// Format backtest status for display
fn format_backtest_status(status: BacktestStatus) -> colored::ColoredString {
match status {
BacktestStatus::Queued => "QUEUED".bright_yellow(),
BacktestStatus::Running => "RUNNING".bright_cyan(),
BacktestStatus::Completed => "COMPLETED".bright_green(),
BacktestStatus::Failed => "FAILED".bright_red(),
BacktestStatus::Cancelled => "CANCELLED".bright_magenta(),
BacktestStatus::Unspecified | BacktestStatus::Paused => "UNKNOWN".bright_red(),
}
}
/// Format order side for display
const fn format_order_side(side: i32) -> &'static str {
match side {
1 => "BUY",
2 => "SELL",
_ => "UNKNOWN",
}
}

View File

@@ -1,517 +1,40 @@
//! Broker connectivity check command
//!
//! Validates IBKR TWS/Gateway connectivity via:
//! 1. Direct ibapi handshake (feature-gated behind `broker-check`)
//! 2. Broker Gateway gRPC health check
//! `fxt broker` -- broker connectivity.
use anyhow::Result;
use clap::{Args, Subcommand};
use colored::Colorize;
use std::time::Instant;
use clap::{Parser, Subcommand};
use crate::config::TliConfig;
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
/// Resolved broker check settings (CLI > env > config > defaults).
#[derive(Debug)]
pub struct ResolvedBrokerConfig {
pub ibkr_host: String,
pub ibkr_port: u16,
pub ibkr_client_id: i32,
pub gateway_url: String,
pub skip_ibkr: bool,
pub skip_gateway: bool,
}
/// Broker command arguments
#[derive(Args, Debug)]
pub struct BrokerArgs {
#[derive(Parser, Debug)]
pub struct BrokerCommand {
#[command(subcommand)]
command: BrokerCommand,
action: BrokerAction,
}
/// Broker subcommands
#[derive(Subcommand, Debug, Clone)]
pub enum BrokerCommand {
/// Check broker connectivity (IB Gateway + Broker Gateway gRPC)
#[clap(
long_about = "Validate IBKR TWS/Gateway connectivity.\n\n\
Runs two checks:\n\
1. Direct IB Gateway: TCP connect + ibapi handshake\n\
2. Broker Gateway gRPC: HealthCheck + GetSessionStatus RPCs\n\n\
Config precedence: CLI flags > env vars > ~/.foxhunt/config.toml > defaults\n\n\
Examples:\n\
fxt broker check\n\
fxt broker check --host 10.0.0.5 --port 4002\n\
fxt broker check --skip-ibkr\n\
fxt broker check --skip-gateway"
)]
Check(CheckArgs),
#[derive(Subcommand, Debug)]
enum BrokerAction {
/// Show broker connection status
Status,
/// Connect to broker gateway
Connect {
/// Broker host override
#[arg(long)]
host: Option<String>,
/// Broker port override
#[arg(long)]
port: Option<u16>,
},
/// List recent executions
Executions {
/// Maximum number of executions to show
#[arg(long, default_value = "20")]
limit: u32,
},
}
/// Arguments for `fxt broker check`
#[derive(Debug, Args, Clone)]
pub struct CheckArgs {
/// IB Gateway host
#[arg(long, env = "IBKR_HOST")]
pub host: Option<String>,
/// IB Gateway socat port (4004 = paper, 4003 = live)
#[arg(long, env = "IBKR_PORT")]
pub port: Option<u16>,
/// TWS client ID (must be unique per connection)
#[arg(long, env = "IBKR_CLIENT_ID")]
pub client_id: Option<i32>,
/// Broker Gateway gRPC URL
#[arg(long, env = "BROKER_GATEWAY_URL")]
pub gateway_url: Option<String>,
/// Skip direct IB Gateway check
#[arg(long, default_value_t = false)]
pub skip_ibkr: bool,
/// Skip Broker Gateway gRPC check
#[arg(long, default_value_t = false)]
pub skip_gateway: bool,
}
/// Resolve config: CLI flags > env vars (handled by clap) > config file > defaults.
pub fn resolve_config(args: &CheckArgs, config: &TliConfig) -> ResolvedBrokerConfig {
ResolvedBrokerConfig {
ibkr_host: args
.host
.clone()
.unwrap_or_else(|| config.broker.ibkr.host.clone()),
ibkr_port: args.port.unwrap_or(config.broker.ibkr.port),
ibkr_client_id: args.client_id.unwrap_or(config.broker.ibkr.client_id),
gateway_url: args
.gateway_url
.clone()
.unwrap_or_else(|| config.broker.gateway_url.clone()),
skip_ibkr: args.skip_ibkr,
skip_gateway: args.skip_gateway,
}
}
/// Single check result
#[derive(Debug)]
pub struct CheckResult {
pub name: String,
pub passed: bool,
pub detail: String,
pub duration_ms: u128,
}
/// Print a check result line
fn print_check(result: &CheckResult) {
let status = if result.passed {
"\u{2713}".green().bold()
} else {
"\u{2717}".red().bold()
};
let dots = ".".repeat(24_usize.saturating_sub(result.name.len()));
println!(
" {} {} {} {}",
result.name, dots, status, result.detail
);
}
// ── IB Gateway direct check ──────────────────────────────────────────
/// TCP connect probe (fail-fast before ibapi handshake).
async fn check_tcp_connect(host: &str, port: u16) -> CheckResult {
let addr = format!("{host}:{port}");
let start = Instant::now();
match tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::TcpStream::connect(&addr),
)
.await
{
Ok(Ok(_)) => CheckResult {
name: "TCP connect".to_owned(),
passed: true,
detail: format!("{addr} ({}ms)", start.elapsed().as_millis()),
duration_ms: start.elapsed().as_millis(),
},
Ok(Err(e)) => CheckResult {
name: "TCP connect".to_owned(),
passed: false,
detail: format!("{e} ({addr})"),
duration_ms: start.elapsed().as_millis(),
},
Err(_) => CheckResult {
name: "TCP connect".to_owned(),
passed: false,
detail: format!("Timeout after 5s ({addr})"),
duration_ms: start.elapsed().as_millis(),
},
}
}
/// Full ibapi handshake (blocking, runs on spawn_blocking).
/// Retries once after 1s if the first attempt fails (IB Gateway may need time
/// to release a stale client_id slot after a previous disconnection).
#[cfg(feature = "broker-check")]
async fn check_ibapi_handshake(host: &str, port: u16, client_id: i32) -> CheckResult {
let addr = format!("{host}:{port}");
let start = Instant::now();
let a1 = addr.clone();
let first = tokio::task::spawn_blocking(move || ibapi::Client::connect(&a1, client_id)).await;
match first {
Ok(Ok(client)) => {
let sv = client.server_version();
return CheckResult {
name: "ibapi handshake".to_owned(),
passed: true,
detail: format!(
"client_id={client_id}, server v{sv} ({}ms)",
start.elapsed().as_millis()
),
duration_ms: start.elapsed().as_millis(),
};
}
Ok(Err(_)) => {
// Retry once — IB Gateway may need time to release stale client_id slot
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
Err(e) => {
return CheckResult {
name: "ibapi handshake".to_owned(),
passed: false,
detail: format!("Task join error: {e}"),
duration_ms: start.elapsed().as_millis(),
};
}
}
let a2 = addr;
match tokio::task::spawn_blocking(move || ibapi::Client::connect(&a2, client_id)).await {
Ok(Ok(client)) => {
let sv = client.server_version();
CheckResult {
name: "ibapi handshake".to_owned(),
passed: true,
detail: format!(
"client_id={client_id}, server v{sv} ({}ms, retry)",
start.elapsed().as_millis()
),
duration_ms: start.elapsed().as_millis(),
}
}
Ok(Err(e)) => CheckResult {
name: "ibapi handshake".to_owned(),
passed: false,
detail: format!("{e}"),
duration_ms: start.elapsed().as_millis(),
},
Err(e) => CheckResult {
name: "ibapi handshake".to_owned(),
passed: false,
detail: format!("Task join error: {e}"),
duration_ms: start.elapsed().as_millis(),
},
}
}
#[cfg(not(feature = "broker-check"))]
async fn check_ibapi_handshake(_host: &str, _port: u16, _client_id: i32) -> CheckResult {
CheckResult {
name: "ibapi handshake".to_owned(),
passed: false,
detail: "Skipped (compile with --features broker-check)".to_owned(),
duration_ms: 0,
}
}
// ── Broker Gateway gRPC check ────────────────────────────────────────
/// gRPC HealthCheck against broker_gateway_service.
async fn check_grpc_health(gateway_url: &str) -> CheckResult {
use crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient;
use crate::proto::broker_gateway::HealthCheckRequest;
let start = Instant::now();
let channel = match tonic::transport::Channel::from_shared(gateway_url.to_owned()) {
Ok(endpoint) => {
match tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.connect()).await
{
Ok(Ok(ch)) => ch,
Ok(Err(e)) => {
return CheckResult {
name: "Health check".to_owned(),
passed: false,
detail: format!("Connection failed: {e}"),
duration_ms: start.elapsed().as_millis(),
};
}
Err(_) => {
return CheckResult {
name: "Health check".to_owned(),
passed: false,
detail: format!("Timeout after 5s ({gateway_url})"),
duration_ms: start.elapsed().as_millis(),
};
}
}
}
Err(e) => {
return CheckResult {
name: "Health check".to_owned(),
passed: false,
detail: format!("Invalid URL: {e}"),
duration_ms: start.elapsed().as_millis(),
};
}
};
let mut client = BrokerGatewayServiceClient::new(channel);
match client.health_check(HealthCheckRequest {}).await {
Ok(resp) => {
let inner = resp.into_inner();
CheckResult {
name: "Health check".to_owned(),
passed: inner.healthy,
detail: format!(
"{} ({}ms)",
inner.message,
start.elapsed().as_millis()
),
duration_ms: start.elapsed().as_millis(),
}
}
Err(e) => CheckResult {
name: "Health check".to_owned(),
passed: false,
detail: format!("{} ({})", e.code(), e.message()),
duration_ms: start.elapsed().as_millis(),
},
}
}
/// gRPC GetSessionStatus against broker_gateway_service.
async fn check_grpc_session(gateway_url: &str) -> CheckResult {
use crate::proto::broker_gateway::broker_gateway_service_client::BrokerGatewayServiceClient;
use crate::proto::broker_gateway::GetSessionStatusRequest;
let start = Instant::now();
let channel = match tonic::transport::Channel::from_shared(gateway_url.to_owned()) {
Ok(endpoint) => match endpoint.connect().await {
Ok(ch) => ch,
Err(e) => {
return CheckResult {
name: "Session status".to_owned(),
passed: false,
detail: format!("Connection failed: {e}"),
duration_ms: start.elapsed().as_millis(),
};
}
},
Err(e) => {
return CheckResult {
name: "Session status".to_owned(),
passed: false,
detail: format!("Invalid URL: {e}"),
duration_ms: start.elapsed().as_millis(),
};
}
};
let mut client = BrokerGatewayServiceClient::new(channel);
match client
.get_session_status(GetSessionStatusRequest { session_id: None })
.await
{
Ok(resp) => {
let inner = resp.into_inner();
let state_name = match inner.state {
0 => "DISCONNECTED",
1 => "CONNECTED",
2 => "LOGGING_IN",
3 => "ACTIVE",
4 => "LOGGING_OUT",
_ => "UNKNOWN",
};
CheckResult {
name: "Session status".to_owned(),
passed: inner.state >= 1, // CONNECTED or better
detail: format!(
"{state_name} (seq: {}/{}, RTT: {:.1}ms)",
inner.sender_seq_num, inner.target_seq_num, inner.heartbeat_rtt_ms
),
duration_ms: start.elapsed().as_millis(),
}
}
Err(e) => CheckResult {
name: "Session status".to_owned(),
passed: false,
detail: format!("{} ({})", e.code(), e.message()),
duration_ms: start.elapsed().as_millis(),
},
}
}
// ── Orchestrator ─────────────────────────────────────────────────────
/// Run all broker checks and return overall pass/fail.
pub async fn run_broker_check(resolved: &ResolvedBrokerConfig) -> Result<bool> {
println!("{}", "Broker Connectivity Check".bold());
println!("{}", "\u{2550}".repeat(40));
let mut all_passed = true;
// ── IB Gateway (direct) ──
if !resolved.skip_ibkr {
println!("{}", "IB Gateway (direct)".bold());
let tcp = check_tcp_connect(&resolved.ibkr_host, resolved.ibkr_port).await;
print_check(&tcp);
if tcp.passed {
let handshake = check_ibapi_handshake(
&resolved.ibkr_host,
resolved.ibkr_port,
resolved.ibkr_client_id,
)
.await;
print_check(&handshake);
if !handshake.passed {
all_passed = false;
}
} else {
all_passed = false;
}
println!();
}
// ── Broker Gateway (gRPC) ──
if !resolved.skip_gateway {
println!("{}", "Broker Gateway (gRPC)".bold());
let health = check_grpc_health(&resolved.gateway_url).await;
print_check(&health);
if health.passed {
let session = check_grpc_session(&resolved.gateway_url).await;
print_check(&session);
if !session.passed {
all_passed = false;
}
} else {
all_passed = false;
}
println!();
}
// ── Summary ──
if all_passed {
println!("{}", "Result: All checks passed \u{2713}".green().bold());
} else {
println!("{}", "Result: One or more checks failed \u{2717}".red().bold());
}
Ok(all_passed)
}
/// Execute broker command (public interface for main.rs).
pub async fn execute_broker_command(args: BrokerArgs, config: &TliConfig) -> Result<bool> {
match args.command {
BrokerCommand::Check(check_args) => {
let resolved = resolve_config(&check_args, config);
run_broker_check(&resolved).await
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::config::TliConfig;
#[test]
fn test_resolve_config_uses_defaults() {
let args = CheckArgs {
host: None,
port: None,
client_id: None,
gateway_url: None,
skip_ibkr: false,
skip_gateway: false,
};
let config = TliConfig::default();
let resolved = resolve_config(&args, &config);
assert_eq!(resolved.ibkr_host, "127.0.0.1");
assert_eq!(resolved.ibkr_port, 4004);
assert_eq!(resolved.ibkr_client_id, 99);
assert_eq!(resolved.gateway_url, "http://localhost:50056");
assert!(!resolved.skip_ibkr);
assert!(!resolved.skip_gateway);
}
#[test]
fn test_resolve_config_cli_overrides() {
let args = CheckArgs {
host: Some("10.0.0.5".to_owned()),
port: Some(4001),
client_id: Some(7),
gateway_url: Some("http://custom:9090".to_owned()),
skip_ibkr: true,
skip_gateway: false,
};
let config = TliConfig::default();
let resolved = resolve_config(&args, &config);
assert_eq!(resolved.ibkr_host, "10.0.0.5");
assert_eq!(resolved.ibkr_port, 4001);
assert_eq!(resolved.ibkr_client_id, 7);
assert_eq!(resolved.gateway_url, "http://custom:9090");
assert!(resolved.skip_ibkr);
}
#[test]
fn test_resolve_config_file_overrides_defaults() {
let args = CheckArgs {
host: None,
port: None,
client_id: None,
gateway_url: None,
skip_ibkr: false,
skip_gateway: false,
};
let config: TliConfig = toml::from_str(
r#"
[broker]
gateway_url = "http://broker-gw:50060"
[broker.ibkr]
host = "192.168.1.100"
port = 4001
client_id = 55
"#,
)
.unwrap();
let resolved = resolve_config(&args, &config);
assert_eq!(resolved.ibkr_host, "192.168.1.100");
assert_eq!(resolved.ibkr_port, 4001);
assert_eq!(resolved.ibkr_client_id, 55);
assert_eq!(resolved.gateway_url, "http://broker-gw:50060");
}
#[tokio::test]
async fn test_tcp_connect_refused() {
// Connect to a port that's almost certainly not listening
let result = check_tcp_connect("127.0.0.1", 19999).await;
assert!(!result.passed);
assert_eq!(result.name, "TCP connect");
}
#[tokio::test]
async fn test_grpc_health_unreachable() {
let result = check_grpc_health("http://127.0.0.1:19998").await;
assert!(!result.passed);
assert_eq!(result.name, "Health check");
impl BrokerCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("broker command not yet implemented")
}
}

View File

@@ -0,0 +1,33 @@
//! `fxt cluster` -- cluster operations.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct ClusterCommand {
#[command(subcommand)]
action: ClusterAction,
}
#[derive(Subcommand, Debug)]
enum ClusterAction {
/// Show cluster resource utilization
Status,
/// Show node and pod resource details
Resources,
/// Show recent cluster events
Events {
/// Maximum number of events to show
#[arg(long, default_value = "50")]
limit: u32,
},
}
impl ClusterCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("cluster command not yet implemented")
}
}

View File

@@ -0,0 +1,39 @@
//! `fxt config` -- system configuration.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct ConfigCommand {
#[command(subcommand)]
action: ConfigAction,
}
#[derive(Subcommand, Debug)]
enum ConfigAction {
/// Get a configuration value
Get {
/// Configuration key
key: String,
},
/// Set a configuration value
Set {
/// Configuration key
key: String,
/// Configuration value
value: String,
},
/// Export full configuration as TOML
Export,
/// Show resolved environment (merged CLI + env + config file)
Env,
}
impl ConfigCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("config command not yet implemented")
}
}

View File

@@ -0,0 +1,44 @@
//! `fxt data` -- data pipeline management.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct DataCommand {
#[command(subcommand)]
action: DataAction,
}
#[derive(Subcommand, Debug)]
enum DataAction {
/// Download market data
Download {
/// Symbol to download
#[arg(long)]
symbol: String,
/// Start date (YYYY-MM-DD)
#[arg(long)]
from: String,
/// End date (YYYY-MM-DD)
#[arg(long)]
to: String,
/// Schema (ohlcv-1m, ohlcv-1s, trades, mbp-1)
#[arg(long, default_value = "ohlcv-1m")]
schema: String,
},
/// Show data pipeline status
Status,
/// Show data cache inventory
Cache,
/// Show live data feed status
Feeds,
}
impl DataCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("data command not yet implemented")
}
}

View File

@@ -0,0 +1,18 @@
//! `fxt mcp` -- MCP (Model Context Protocol) server mode.
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
pub struct McpCommand;
impl McpCommand {
/// Start an MCP server that exposes Foxhunt operations as tools
/// for LLM agents (e.g. Claude, Cursor).
///
/// This command does not need a pre-built gRPC client --
/// the server manages its own connections.
pub async fn execute(&self) -> Result<()> {
anyhow::bail!("mcp server not yet implemented")
}
}

View File

@@ -1,38 +1,17 @@
//! TLI Command Modules
//!
//! Command-line interface subcommands for TLI operations.
//! Each module implements a specific command category with rich terminal output.
//!
//! # Available Commands
//! - `tune` - Hyperparameter tuning job management (start, status, best, stop)
//! - `train` - Training job management (list, status, details)
//! - `model` - Model promotion management (list, approve, reject)
//! - `agent` - Trading agent operations (status, performance)
//!
//! # Future Commands (Planned)
//! - `backtest` - Backtesting operations
//! - `trading` - Trading operations (orders, positions)
//! - `risk` - Risk management queries
//! - `config` - Configuration management
//! CLI command modules -- one file per top-level subcommand.
pub mod agent;
pub mod auth;
pub mod backtest_ml;
pub mod backtest;
pub mod broker;
pub mod cluster;
pub mod config_cmd;
pub mod data;
pub mod mcp;
pub mod model;
pub mod risk;
pub mod service;
pub mod trade;
pub mod trade_ml;
pub mod train;
pub mod tune;
pub mod watch;
pub mod tune_stream;
pub use agent::{execute_agent_command, AgentArgs};
pub use auth::{execute_auth_command, AuthCommand};
pub use broker::{execute_broker_command, BrokerArgs};
pub use backtest_ml::{execute_backtest_ml_command, BacktestMlArgs, BacktestMlCommand};
pub use model::{execute_model_command, ModelCommand};
pub use trade::{execute_trade_command, TradeArgs};
pub use trade_ml::{execute_trade_ml_command, TradeMlArgs};
pub use train::{execute_train_command, TrainCommand};
pub use tune::{execute_tune_command, TuneCommand};

View File

@@ -0,0 +1,55 @@
//! `fxt model` -- model management and promotion.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct ModelCommand {
#[command(subcommand)]
action: ModelAction,
}
#[derive(Subcommand, Debug)]
enum ModelAction {
/// List registered models
List {
/// Filter by model type
#[arg(long)]
model: Option<String>,
/// Filter by status (staging, production, archived)
#[arg(long)]
status: Option<String>,
},
/// Show detailed model status
Status {
/// Model ID
model_id: String,
},
/// Run inference on a model
Predict {
/// Model ID
model_id: String,
/// Symbol to predict
#[arg(long)]
symbol: String,
},
/// Show ensemble composition and weights
Ensemble,
/// Promote a model to production
Promote {
/// Model ID
model_id: String,
/// Reason for promotion
#[arg(long)]
reason: Option<String>,
},
}
impl ModelCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("model command not yet implemented")
}
}

View File

@@ -1,49 +0,0 @@
//! FXT Model Approve Command - Approve a Pending Model Promotion
//!
//! Approves a model for promotion to production.
//!
//! # Usage
//!
//! ```bash
//! fxt model approve <MODEL_ID>
//! ```
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient, ApproveModelRequest,
};
use anyhow::{Context, Result};
use tonic::{metadata::MetadataValue, Request};
/// Approve a pending model promotion.
///
/// Calls `ApproveModel` on the ML training service via the API gateway
/// to promote the model to production.
pub async fn run(api_gateway_url: &str, jwt_token: &str, model_id: &str) -> Result<()> {
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = MlTrainingServiceClient::new(channel);
let mut request = Request::new(ApproveModelRequest {
model_id: model_id.to_owned(),
promoted_to: "production".to_owned(),
});
let token_value = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
request
.metadata_mut()
.insert("authorization", token_value);
let response = client
.approve_model(request)
.await
.context("Failed to approve model")?
.into_inner();
if response.success {
println!("Model '{}' approved for production.", model_id);
} else {
println!("Failed to approve model '{}': {}", model_id, response.message);
}
Ok(())
}

View File

@@ -1,72 +0,0 @@
//! FXT Model List Command - Display Available Models for Training
//!
//! Lists all available model types that can be trained.
//!
//! # Usage
//!
//! ```bash
//! fxt model list
//! ```
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient, ListAvailableModelsRequest,
};
use anyhow::{Context, Result};
use tonic::{metadata::MetadataValue, Request};
/// List available models for training.
///
/// Calls `ListAvailableModels` on the ML training service via the API gateway
/// and displays a formatted table of available model types.
pub async fn run(api_gateway_url: &str, jwt_token: &str) -> Result<()> {
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = MlTrainingServiceClient::new(channel);
let mut request = Request::new(ListAvailableModelsRequest {});
let token_value = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
request
.metadata_mut()
.insert("authorization", token_value);
let response = client
.list_available_models(request)
.await
.context("Failed to list available models")?
.into_inner();
let models = response.models;
if models.is_empty() {
println!("No available models found.");
return Ok(());
}
println!("Available Models for Training");
println!("{:-<80}", "");
println!(
"{:<15} {:<35} {:<8} {:<10}",
"Model Type", "Description", "GPU", "Est. Time"
);
println!("{:-<80}", "");
for model in &models {
let gpu_label = if model.requires_gpu { "Yes" } else { "No" };
let time_label = if model.estimated_training_time_minutes > 0 {
format!("{}m", model.estimated_training_time_minutes)
} else {
"N/A".to_owned()
};
println!(
"{:<15} {:<35} {:<8} {:<10}",
model.model_type, model.description, gpu_label, time_label
);
}
println!("{:-<80}", "");
println!("Total: {} model(s)", models.len());
Ok(())
}

View File

@@ -1,52 +0,0 @@
//! FXT Model Command Module
//!
//! Provides CLI interface for managing ML model promotions.
//!
//! # Subcommands
//! - `list` - List models with pending promotion status
//! - `approve` - Approve a pending model promotion
//! - `reject` - Reject a pending model promotion
pub mod approve;
pub mod list;
pub mod reject;
use anyhow::Result;
use clap::Subcommand;
/// Model management subcommands
#[derive(Subcommand, Debug)]
pub enum ModelCommand {
/// List models with active/pending promotion status
List,
/// Approve a pending model promotion
Approve {
/// Model ID to approve
model_id: String,
},
/// Reject a pending model promotion
Reject {
/// Model ID to reject
model_id: String,
/// Reason for rejection
#[arg(long, default_value = "operator rejected")]
reason: String,
},
}
/// Execute model command
pub async fn execute_model_command(
command: ModelCommand,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
match command {
ModelCommand::List => list::run(api_gateway_url, jwt_token).await,
ModelCommand::Approve { model_id } => {
approve::run(api_gateway_url, jwt_token, &model_id).await
}
ModelCommand::Reject { model_id, reason } => {
reject::run(api_gateway_url, jwt_token, &model_id, &reason).await
}
}
}

View File

@@ -1,55 +0,0 @@
//! FXT Model Reject Command - Reject a Pending Model Promotion
//!
//! Rejects a model promotion and records the reason.
//!
//! # Usage
//!
//! ```bash
//! fxt model reject <MODEL_ID>
//! fxt model reject <MODEL_ID> --reason "metrics degraded vs baseline"
//! ```
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient, RejectModelRequest,
};
use anyhow::{Context, Result};
use tonic::{metadata::MetadataValue, Request};
/// Reject a pending model promotion.
///
/// Calls `RejectModel` on the ML training service via the API gateway
/// to reject the model promotion with an operator-supplied reason.
pub async fn run(
api_gateway_url: &str,
jwt_token: &str,
model_id: &str,
reason: &str,
) -> Result<()> {
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = MlTrainingServiceClient::new(channel);
let mut request = Request::new(RejectModelRequest {
model_id: model_id.to_owned(),
reason: reason.to_owned(),
});
let token_value = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
request
.metadata_mut()
.insert("authorization", token_value);
let response = client
.reject_model(request)
.await
.context("Failed to reject model")?
.into_inner();
if response.success {
println!("Model '{}' rejected. Reason: {}", model_id, reason);
} else {
println!("Failed to reject model '{}': {}", model_id, response.message);
}
Ok(())
}

View File

@@ -0,0 +1,46 @@
//! `fxt risk` -- risk management.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct RiskCommand {
#[command(subcommand)]
action: RiskAction,
}
#[derive(Subcommand, Debug)]
enum RiskAction {
/// Show current risk status (VaR, exposure, limits)
Status,
/// Show/update risk limits
Limits {
/// Limit key to get/set (e.g. max_position_size)
key: Option<String>,
/// Value to set (omit to read current value)
value: Option<String>,
},
/// Show drawdown analysis
Drawdown,
/// Run stress test scenarios
Stress {
/// Scenario name (e.g. flash-crash, vol-spike)
#[arg(long)]
scenario: Option<String>,
},
/// Emergency: kill switch
Emergency {
/// Confirm emergency shutdown
#[arg(long)]
confirm: bool,
},
}
impl RiskCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("risk command not yet implemented")
}
}

View File

@@ -0,0 +1,50 @@
//! `fxt service` -- service operations.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct ServiceCommand {
#[command(subcommand)]
action: ServiceAction,
}
#[derive(Subcommand, Debug)]
enum ServiceAction {
/// List all services with health status
List,
/// Detailed service status
Status {
/// Service name
service: String,
},
/// Stream service logs
Logs {
/// Service name
service: String,
/// Follow log output
#[arg(long, short)]
follow: bool,
},
/// Restart a service
Restart {
/// Service name
service: String,
},
/// Deploy binary update
Deploy {
/// Service name
service: String,
},
/// Health check all services
Health,
}
impl ServiceCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("service command not yet implemented")
}
}

View File

@@ -1,129 +1,49 @@
//! TLI Trade Commands
//!
//! Trading operations with ML-powered decision making.
//!
//! # Architecture
//! This module acts as a routing layer for trade-related commands:
//! - `ml` - ML-powered trading operations (ensemble voting, predictions, performance)
//!
//! # Command Flow
//! User → main.rs → trade.rs → `trade_ml.rs` → API Gateway → Trading Service
//!
//! # Future Extensions
//! - `manual` - Manual order submission
//! - `modify` - Order modification
//! - `cancel` - Order cancellation
//! `fxt trade` -- order management.
use anyhow::Result;
use clap::{Args, Subcommand};
use clap::{Parser, Subcommand};
use crate::commands::trade_ml::{execute_trade_ml_command, TradeMlArgs};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
/// Trade command arguments
#[derive(Debug, Args)]
pub struct TradeArgs {
#[derive(Parser, Debug)]
pub struct TradeCommand {
#[command(subcommand)]
pub command: TradeCommand,
action: TradeAction,
}
/// Trade subcommands
#[derive(Debug, Subcommand)]
pub enum TradeCommand {
/// ML-powered trading commands
#[command(name = "ml")]
Ml(TradeMlArgs),
#[derive(Subcommand, Debug)]
enum TradeAction {
/// Submit a new order
Submit {
/// Trading symbol (e.g. ES.FUT)
#[arg(long)]
symbol: String,
/// Order side: buy or sell
#[arg(long)]
side: String,
/// Quantity
#[arg(long)]
qty: f64,
/// Limit price (omit for market orders)
#[arg(long)]
price: Option<f64>,
},
/// Cancel an open order
Cancel {
/// Order ID to cancel
order_id: String,
},
/// List open positions
Positions,
/// List open orders
Orders,
/// Account summary (balance, margin, P&L)
Account,
}
/// Execute trade command
///
/// # Arguments
/// * `args` - Trade command arguments (contains subcommand)
/// * `api_gateway_url` - API Gateway URL for gRPC connection
/// * `jwt_token` - JWT authentication token
///
/// # Returns
/// - `Ok(())` - Command executed successfully
/// - `Err(anyhow::Error)` - Command execution failed
///
/// # Routing
/// This function routes to the appropriate subcommand handler:
/// - `TradeCommand::Ml` → `execute_trade_ml_command()`
///
/// # Example
/// ```no_run
/// use fxt::commands::trade::{TradeArgs, TradeCommand, execute_trade_command};
/// use fxt::commands::trade_ml::TradeMlArgs;
///
/// # async fn example() -> anyhow::Result<()> {
/// let args = TradeArgs {
/// command: TradeCommand::Ml(TradeMlArgs { /* ... */ }),
/// };
///
/// execute_trade_command(args, "http://localhost:50051", "jwt-token").await?;
/// # Ok(())
/// # }
/// ```
pub async fn execute_trade_command(
args: TradeArgs,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
match args.command {
TradeCommand::Ml(ml_args) => {
execute_trade_ml_command(ml_args, api_gateway_url, jwt_token).await
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trade_args_structure() {
// Verify TradeArgs struct is correctly defined
// This ensures the command structure is valid for clap parsing
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
trade_args: TradeArgs,
}
// Test that the structure compiles and can be parsed
// (Actual parsing is tested in main.rs integration tests)
}
#[test]
fn test_trade_command_variants() {
// Verify TradeCommand enum has expected variants
use crate::commands::trade_ml::TradeMlArgs;
let _ml_variant = TradeCommand::Ml(TradeMlArgs {
command: crate::commands::trade_ml::TradeMlCommand::Performance { model: None },
});
// Test compiles = variants are correctly defined
}
#[tokio::test]
async fn test_execute_trade_command_routing() {
use crate::commands::trade_ml::{TradeMlArgs, TradeMlCommand};
// Create a test TradeArgs with ML subcommand
let args = TradeArgs {
command: TradeCommand::Ml(TradeMlArgs {
command: TradeMlCommand::Performance {
model: Some("DQN".to_owned()),
},
}),
};
// Execute command (will fail due to no actual API Gateway, but tests routing)
let result = execute_trade_command(args, "http://localhost:50051", "mock-token").await;
// Should attempt to execute (may fail due to connection, but routing works)
assert!(result.is_ok() || result.is_err());
impl TradeCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("trade command not yet implemented")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
//! `fxt train` -- ML training lifecycle.
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::grpc::FoxhuntClient;
use crate::output::OutputFormat;
#[derive(Parser, Debug)]
pub struct TrainCommand {
#[command(subcommand)]
action: TrainAction,
}
#[derive(Subcommand, Debug)]
enum TrainAction {
/// Start a training job
Start {
/// Model type (e.g. dqn, ppo, tft, mamba2)
#[arg(long)]
model: String,
/// Path to training config YAML
#[arg(long)]
config: Option<String>,
/// Enable GPU training
#[arg(long)]
gpu: bool,
},
/// Stop a running training job
Stop {
/// Training job ID
job_id: String,
},
/// Show training job status
Status {
/// Training job ID
job_id: String,
},
/// List training jobs
List {
/// Filter by status (running, completed, failed)
#[arg(long)]
status: Option<String>,
/// Filter by model type
#[arg(long)]
model: Option<String>,
},
/// Stream training logs
Logs {
/// Training job ID
job_id: String,
/// Follow log output
#[arg(long, short)]
follow: bool,
},
}
impl TrainCommand {
pub async fn execute(&self, _client: &FoxhuntClient, _format: OutputFormat) -> Result<()> {
anyhow::bail!("train command not yet implemented")
}
}

View File

@@ -1,348 +0,0 @@
//! TLI Train List Command - Display Training Job History
//!
//! Provides CLI interface for listing and filtering ML model training jobs.
//!
//! # Usage
//!
//! ```bash
//! # List all jobs (default: last 50)
//! tli train list
//!
//! # Filter by status
//! tli train list --status RUNNING
//! tli train list --status COMPLETED
//!
//! # Filter by model type
//! tli train list --model TFT
//! tli train list --model DQN
//!
//! # Filter by asset
//! tli train list --asset ES.FUT
//!
//! # Sort by different fields
//! tli train list --sort-by start_time --sort-order desc
//! tli train list --sort-by duration --sort-order asc
//!
//! # Limit results
//! tli train list --limit 10
//!
//! # Show only batch or single jobs
//! tli train list --batch-only
//! tli train list --single-only
//!
//! # Combined filters
//! tli train list --status RUNNING --model TFT --limit 5
//! ```
use anyhow::{Context, Result};
use chrono::Utc;
use clap::Parser;
use comfy_table::{presets::UTF8_FULL, Attribute, Cell, Color, ContentArrangement, Table};
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient, ListTrainingJobsRequest,
TrainingJobSummary, TrainingStatus,
};
use tonic::{metadata::MetadataValue, Request};
/// List training jobs with filtering and sorting
#[derive(Parser, Debug)]
pub struct ListCommand {
/// Filter by job status (PENDING, RUNNING, COMPLETED, FAILED, STOPPED)
#[clap(long)]
pub status: Option<String>,
/// Filter by model type (DQN, PPO, `MAMBA_2`, TFT, TLOB, LIQUID)
#[clap(long)]
pub model: Option<String>,
/// Filter by asset (ES.FUT, NQ.FUT, etc.)
#[clap(long)]
pub asset: Option<String>,
/// Sort by field (`start_time`, duration, status)
#[clap(long, default_value = "start_time")]
pub sort_by: String,
/// Sort order (asc, desc)
#[clap(long, default_value = "desc")]
pub sort_order: String,
/// Maximum results to display
#[clap(long, default_value = "50")]
pub limit: u32,
/// Show only batch jobs
#[clap(long, conflicts_with = "single_only")]
pub batch_only: bool,
/// Show only single-model jobs
#[clap(long, conflicts_with = "batch_only")]
pub single_only: bool,
}
impl ListCommand {
/// Execute the list command
pub async fn run(&self, api_gateway_url: &str, jwt_token: &str) -> Result<()> {
// Connect to API Gateway
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = MlTrainingServiceClient::new(channel);
// Build request with filters
let status_filter = self.parse_status_filter()?;
let list_request = self.build_request(status_filter);
// Add JWT authentication
let mut request = Request::new(list_request);
let token_value = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
request
.metadata_mut()
.insert("authorization", token_value);
// Call gRPC service
let response = client
.list_training_jobs(request)
.await
.context("Failed to list training jobs")?
.into_inner();
// Apply client-side filtering and sorting
let mut jobs = response.jobs.clone();
jobs = self.apply_filters(jobs);
jobs = self.apply_sorting(jobs);
jobs.truncate(self.limit as usize);
// Display results
if jobs.is_empty() {
println!("No training jobs found.");
return Ok(());
}
let total_count = response.total_count;
self.display_jobs_table(&jobs)?;
self.display_summary(&jobs, total_count);
Ok(())
}
/// Parse status filter string to enum
fn parse_status_filter(&self) -> Result<TrainingStatus> {
if let Some(status_str) = &self.status {
match status_str.to_uppercase().as_str() {
"PENDING" => Ok(TrainingStatus::Pending),
"RUNNING" => Ok(TrainingStatus::Running),
"COMPLETED" => Ok(TrainingStatus::Completed),
"FAILED" => Ok(TrainingStatus::Failed),
"STOPPED" => Ok(TrainingStatus::Stopped),
_ => Ok(TrainingStatus::Unknown),
}
} else {
Ok(TrainingStatus::Unknown)
}
}
/// Build gRPC request
fn build_request(&self, status_filter: TrainingStatus) -> ListTrainingJobsRequest {
ListTrainingJobsRequest {
page: 1,
page_size: 500, // Fetch more for client-side filtering
status_filter: status_filter as i32,
model_type_filter: self.model.clone().unwrap_or_default(),
start_time: 0,
end_time: 0,
}
}
/// Apply client-side filters
fn apply_filters(&self, mut jobs: Vec<TrainingJobSummary>) -> Vec<TrainingJobSummary> {
// Filter by asset (check tags or description)
if let Some(asset) = &self.asset {
jobs.retain(|job| {
(job.tags.get("asset") == Some(asset))
|| job.description.contains(asset)
});
}
// Filter batch vs single jobs
if self.batch_only {
jobs.retain(|job| job.model_type.to_uppercase() == "BATCH" || job.job_id.starts_with("batch_"));
} else if self.single_only {
jobs.retain(|job| job.model_type.to_uppercase() != "BATCH" && !job.job_id.starts_with("batch_"));
} else {
// No batch/single filter applied
}
jobs
}
/// Apply sorting
fn apply_sorting(&self, mut jobs: Vec<TrainingJobSummary>) -> Vec<TrainingJobSummary> {
match self.sort_by.as_str() {
"start_time" => {
jobs.sort_by_key(|job| job.started_at);
}
"duration" => {
jobs.sort_by_key(|job| {
if job.completed_at > 0 {
job.completed_at - job.started_at
} else {
0
}
});
}
"status" => {
jobs.sort_by_key(|job| job.status);
}
_ => {
jobs.sort_by_key(|job| job.started_at);
}
}
// Apply sort order
if self.sort_order == "desc" {
jobs.reverse();
}
jobs
}
/// Display jobs in formatted table
fn display_jobs_table(&self, jobs: &[TrainingJobSummary]) -> Result<()> {
let mut table = Table::new();
table
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic);
// Table header
table.set_header(vec![
Cell::new("Job ID")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Status")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Type")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Model")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Asset(s)")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Duration")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
]);
// Add rows
for job in jobs {
let status_str = self.format_status(job.status);
let job_type = self.determine_job_type(&job.job_id, &job.model_type);
let model = job.model_type.clone();
let assets = job
.tags
.get("asset")
.cloned()
.unwrap_or_else(|| "N/A".to_owned());
let duration = self.format_duration(job.started_at, job.completed_at);
table.add_row(vec![
Cell::new(&job.job_id),
Cell::new(status_str),
Cell::new(job_type),
Cell::new(model),
Cell::new(assets),
Cell::new(duration),
]);
}
println!("{}", table);
Ok(())
}
/// Format status with emoji
fn format_status(&self, status: i32) -> String {
match TrainingStatus::try_from(status).unwrap_or(TrainingStatus::Unknown) {
TrainingStatus::Pending => "\u{23f3} PENDING".to_owned(),
TrainingStatus::Running => "\u{23f3} RUNNING".to_owned(),
TrainingStatus::Completed => "\u{2705} COMPLETE".to_owned(),
TrainingStatus::Failed => "\u{274c} FAILED".to_owned(),
TrainingStatus::Stopped => "\u{1f6d1} STOPPED".to_owned(),
TrainingStatus::Paused => "\u{23f8} PAUSED".to_owned(),
TrainingStatus::Unknown => "\u{2753} UNKNOWN".to_owned(),
}
}
/// Determine if job is batch or single
fn determine_job_type(&self, job_id: &str, model_type: &str) -> &'static str {
if job_id.starts_with("batch_") || model_type.to_uppercase() == "BATCH" {
"Batch"
} else {
"Single"
}
}
/// Format duration
fn format_duration(&self, started_at: i64, completed_at: i64) -> String {
if started_at == 0 {
return "N/A".to_owned();
}
let duration_secs = if completed_at > 0 {
completed_at - started_at
} else {
// Job still running, calculate elapsed time
let now = Utc::now().timestamp();
now - started_at
};
let abs_secs = duration_secs.unsigned_abs();
if abs_secs < 60 {
format!("{}s", abs_secs)
} else if abs_secs < 3600 {
let mins = abs_secs / 60;
let secs = abs_secs % 60;
format!("{}m {}s", mins, secs)
} else {
let hours = abs_secs / 3600;
let mins = (abs_secs % 3600) / 60;
format!("{}h {}m", hours, mins)
}
}
/// Display summary
fn display_summary(
&self,
jobs: &[TrainingJobSummary],
total_count: u32,
) {
println!();
println!("Total: {} jobs (showing {} results)", total_count, jobs.len());
// Show filter info
let mut filters = vec![];
if let Some(status) = &self.status {
filters.push(format!("status={}", status));
}
if let Some(model) = &self.model {
filters.push(format!("model={}", model));
}
if let Some(asset) = &self.asset {
filters.push(format!("asset={}", asset));
}
if self.batch_only {
filters.push("type=batch".to_owned());
}
if self.single_only {
filters.push("type=single".to_owned());
}
if !filters.is_empty() {
println!("Filters: {}", filters.join(", "));
} else {
println!("(Showing last {} jobs)", self.limit);
}
}
}

View File

@@ -1,93 +0,0 @@
//! TLI Train Command Module
//!
//! Provides CLI interface for managing ML model training jobs.
//!
//! # Subcommands
//! - `list` - List training jobs with filtering and sorting
//! - `status` - Query individual training job status (stub for Wave 2 Agent 5)
pub mod list;
pub mod monitor;
pub mod progress_tracker;
pub mod start;
pub mod status;
// Re-export progress tracker types
pub use progress_tracker::{JobProgress, JobStatus, ProgressTracker};
pub use list::ListCommand;
pub use monitor::MonitorCommand;
pub use start::StartCommand;
pub use status::StatusCommand;
use anyhow::Result;
use clap::Subcommand;
/// Train command arguments
#[derive(Subcommand, Debug)]
pub enum TrainCommand {
/// List training jobs with filtering
List {
#[clap(flatten)]
list_args: ListCommand,
},
/// Start an on-demand training job
Start {
#[clap(flatten)]
start_args: start::StartCommand,
},
/// Query training job status (snapshot)
#[clap(
long_about = "Query current status of a training job.\n\n\
Shows:\n\
- Job status (PENDING/RUNNING/COMPLETED/FAILED/STOPPED)\n\
- Training progress and timing\n\
- Performance metrics (Sharpe, hit rate, drawdown)\n\
- Resource usage (GPU memory, elapsed time)\n\
- Model checkpoint path (for completed jobs)\n\
- Error details (for failed jobs)\n\n\
Examples:\n\
tli train status train_tft_es_fut_20251022_143021\n\
tli train status train_batch_001 --verbose"
)]
Status {
#[clap(flatten)]
status_args: StatusCommand,
},
/// Live training metrics monitor (Prometheus via gRPC)
#[clap(
long_about = "Stream live training metrics from the monitoring service.\n\n\
Shows:\n\
- Per-model epoch, loss, validation loss, throughput\n\
- GPU utilization, VRAM, temperature, power\n\
- Hyperopt trial progress and best objective\n\
- Health counters (NaN, gradient explosions, checkpoints)\n\n\
Examples:\n\
fxt train monitor\n\
fxt train monitor --once\n\
fxt train monitor --model dqn --interval 5"
)]
Monitor {
#[clap(flatten)]
monitor_args: MonitorCommand,
},
}
/// Execute train command
pub async fn execute_train_command(
command: TrainCommand,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
match command {
TrainCommand::List { list_args } => list_args.run(api_gateway_url, jwt_token).await,
TrainCommand::Start { start_args } => start_args.run(api_gateway_url, jwt_token).await,
TrainCommand::Status { status_args } => status_args.run(api_gateway_url, jwt_token).await,
TrainCommand::Monitor { monitor_args } => {
monitor_args.run(api_gateway_url, jwt_token).await
}
}
}

View File

@@ -1,274 +0,0 @@
//! fxt train monitor -- live training metrics from monitoring service
//!
//! Connects to the MonitoringService gRPC endpoint and displays a live TUI
//! with per-model epoch progress, GPU telemetry, and health counters.
//! Use `--once` for a single snapshot instead of continuous streaming.
use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use std::io::{self, Write};
use crate::proto::monitoring::{
monitoring_service_client::MonitoringServiceClient, GetLiveTrainingMetricsRequest,
GetLiveTrainingMetricsResponse, StreamTrainingMetricsRequest, TrainingSession,
};
/// Live training metrics monitor
#[derive(Args, Debug)]
pub struct MonitorCommand {
/// Print a single snapshot and exit (no live TUI)
#[arg(long)]
pub once: bool,
/// Filter to a specific model (e.g. "dqn", "ppo", "tft")
#[arg(long)]
pub model: Option<String>,
/// Streaming interval in seconds (default: 3)
#[arg(long, default_value = "3")]
pub interval: u32,
}
impl MonitorCommand {
/// Execute the monitor command -- connect to monitoring service and display metrics
pub async fn run(&self, api_gateway_url: &str, _jwt_token: &str) -> Result<()> {
// monitoring_service runs on a different port -- derive URL from env or default.
// When connecting via Tailscale (*.fxhnt.ai), use monitor.fxhnt.ai:443 (nginx proxy).
// Otherwise, replace the api-gateway port with the monitoring port.
let monitoring_url = std::env::var("MONITORING_SERVICE_URL").unwrap_or_else(|_| {
if api_gateway_url.contains("fxhnt.ai") {
"https://monitor.fxhnt.ai".to_owned()
} else {
api_gateway_url.replace(":50050", ":50057")
}
});
let mut client = MonitoringServiceClient::connect(monitoring_url)
.await
.context("Failed to connect to monitoring service")?;
let model_filter = self.model.clone().unwrap_or_default();
if self.once {
let response = client
.get_live_training_metrics(tonic::Request::new(GetLiveTrainingMetricsRequest {
model_filter,
}))
.await
.context("GetLiveTrainingMetrics failed")?;
render_snapshot(&response.into_inner());
} else {
println!("Connecting to monitoring service...");
let response = client
.stream_training_metrics(tonic::Request::new(StreamTrainingMetricsRequest {
model_filter,
interval_seconds: self.interval,
}))
.await
.context("StreamTrainingMetrics failed")?;
let mut stream = response.into_inner();
while let Some(msg) = stream.message().await? {
render_tui(&msg);
}
}
Ok(())
}
}
fn render_snapshot(resp: &GetLiveTrainingMetricsResponse) {
if let Some(gpu) = &resp.gpu {
println!(
"GPU: {:.0}% util | {:.1}/{:.1} GB VRAM | {:.0}C | {:.0}W",
gpu.utilization_percent,
gpu.memory_used_mb / 1024.0,
gpu.memory_total_mb / 1024.0,
gpu.temperature_celsius,
gpu.power_watts,
);
}
println!("Active K8s training jobs: {}", resp.active_k8s_jobs);
println!();
if resp.sessions.is_empty() {
println!("No active training sessions with metrics.");
return;
}
print_session_table(&resp.sessions);
print_rl_diagnostics(&resp.sessions);
print_hyperopt_summary(&resp.sessions);
print_health_summary(&resp.sessions);
print_financial_metrics(&resp.sessions);
}
fn render_tui(resp: &GetLiveTrainingMetricsResponse) {
// Clear screen
print!("\x1B[2J\x1B[H");
// Intentionally ignoring flush errors on stdout (terminal output, non-critical)
drop(io::stdout().flush());
println!("{}", "=".repeat(72).bright_black());
println!(
"{}",
" Foxhunt Training Monitor"
.bright_white()
.bold()
);
println!("{}", "=".repeat(72).bright_black());
println!();
render_snapshot(resp);
println!();
println!("{}", "Ctrl+C to exit".bright_black());
}
fn print_session_table(sessions: &[TrainingSession]) {
println!(
"{:<10} {:<6} {:<7} {:<10} {:<10} {:<9} {:<10} {:<10}",
"Model".bright_cyan(),
"Fold".bright_cyan(),
"Epoch".bright_cyan(),
"Loss".bright_cyan(),
"Val Loss".bright_cyan(),
"Batch/s".bright_cyan(),
"Grad Norm".bright_cyan(),
"Eval Acc".bright_cyan(),
);
println!("{}", "-".repeat(82).bright_black());
for s in sessions {
let acc = if s.eval_accuracy > 0.0 {
format!("{:.1}%", s.eval_accuracy * 100.0)
} else {
"-".to_owned()
};
let grad = if s.gradient_norm > 0.0 {
format!("{:.4}", s.gradient_norm)
} else {
"-".to_owned()
};
println!(
"{:<10} {:<6} {:<7.0} {:<10.4} {:<10.4} {:<9.1} {:<10} {:<10}",
s.model, s.fold, s.current_epoch, s.epoch_loss, s.validation_loss,
s.batches_per_second, grad, acc,
);
}
}
fn print_rl_diagnostics(sessions: &[TrainingSession]) {
let rl_sessions: Vec<_> = sessions
.iter()
.filter(|s| s.q_value_mean != 0.0 || s.policy_entropy != 0.0)
.collect();
if rl_sessions.is_empty() {
return;
}
println!();
println!("{}", "RL Diagnostics:".bright_cyan());
for s in &rl_sessions {
if s.q_value_mean != 0.0 || s.q_value_max != 0.0 {
println!(
" {}: Q-mean={:.2} Q-max={:.2} | buffer={}",
s.model.bright_white(),
s.q_value_mean,
s.q_value_max,
s.replay_buffer_size,
);
}
if s.policy_entropy != 0.0 || s.kl_divergence != 0.0 {
println!(
" {}: entropy={:.3} KL={:.4} adv-mean={:.4}",
s.model.bright_white(),
s.policy_entropy,
s.kl_divergence,
s.advantage_mean,
);
}
}
}
fn print_hyperopt_summary(sessions: &[TrainingSession]) {
let hyperopt: Vec<_> = sessions.iter().filter(|s| s.is_hyperopt).collect();
if hyperopt.is_empty() {
return;
}
println!();
for s in &hyperopt {
let trial_detail = if s.hyperopt_trial_epoch > 0 {
format!(
" (epoch {}, loss {:.4})",
s.hyperopt_trial_epoch, s.hyperopt_trial_best_loss
)
} else {
String::new()
};
let elapsed = if s.hyperopt_elapsed_seconds > 0.0 {
format!(" | {:.0}s elapsed", s.hyperopt_elapsed_seconds)
} else {
String::new()
};
println!(
"Hyperopt ({}): trial {}/{}{} | best Sharpe {:.2} | {} failures{}",
s.model.bright_magenta(),
s.hyperopt_trial_current,
s.hyperopt_trial_total,
trial_detail,
s.hyperopt_best_objective,
s.hyperopt_trials_failed,
elapsed,
);
}
}
fn print_health_summary(sessions: &[TrainingSession]) {
let total_nan: u32 = sessions.iter().map(|s| s.nan_detected).sum();
let total_grad: u32 = sessions.iter().map(|s| s.gradient_explosions).sum();
let total_ckpt: u32 = sessions.iter().map(|s| s.checkpoint_saves).sum();
println!(
"Health: {} NaN | {} grad explosions | {} checkpoints",
total_nan, total_grad, total_ckpt
);
}
fn print_financial_metrics(sessions: &[TrainingSession]) {
let financial: Vec<_> = sessions
.iter()
.filter(|s| s.epoch_sharpe != 0.0 || s.epoch_win_rate > 0.0)
.collect();
if financial.is_empty() {
return;
}
println!();
println!("{}", "Epoch Financial Metrics:".bright_cyan());
for s in &financial {
let sharpe_colored = if s.epoch_sharpe >= 2.0 {
format!("{:.2}", s.epoch_sharpe).green()
} else if s.epoch_sharpe >= 1.0 {
format!("{:.2}", s.epoch_sharpe).yellow()
} else {
format!("{:.2}", s.epoch_sharpe).red()
};
println!(
" {}: Sharpe={} WinRate={:.1}% MaxDD={:.1}% PF={:.2} Return={:+.2}% Trades={}",
s.model.bright_white(),
sharpe_colored,
s.epoch_win_rate * 100.0,
s.epoch_max_drawdown * 100.0,
s.epoch_profit_factor,
s.epoch_total_return * 100.0,
s.epoch_total_trades,
);
if s.action_buy_pct > 0.0 || s.action_sell_pct > 0.0 {
println!(
" Actions: BUY {:.0}% | SELL {:.0}% | HOLD {:.0}%",
s.action_buy_pct * 100.0,
s.action_sell_pct * 100.0,
s.action_hold_pct * 100.0,
);
}
}
}

View File

@@ -1,445 +0,0 @@
//! Hierarchical Progress Tracker for ML Training Jobs
//!
//! This module provides thread-safe progress tracking for batch ML training operations,
//! with support for hierarchical parent-child job relationships and weighted progress
//! calculations.
//!
//! # Features
//!
//! - Thread-safe progress tracking using `Arc<Mutex<HashMap>>`
//! - Pull-based rendering with ANSI screen clearing
//! - Hierarchical display: parent batch job → child model jobs
//! - Weighted progress calculation based on model training complexity
//!
//! # Architecture
//!
//! The progress tracker maintains two levels of jobs:
//! - **Parent Jobs**: Represent batch training operations (e.g., "train all models")
//! - **Child Jobs**: Individual model training jobs (DQN, PPO, MAMBA-2, TFT)
//!
//! Progress is calculated using weighted averaging based on model complexity:
//! - DQN: 10% (fastest, simplest model)
//! - PPO: 30% (moderate complexity)
//! - MAMBA-2: 40% (high complexity, GPU-intensive)
//! - TFT: 20% (moderate complexity with INT8 quantization)
use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::time;
/// Job status for tracking progress.
#[derive(Debug, Clone, PartialEq)]
pub enum JobStatus {
/// Job is queued but not started
Pending,
/// Job is currently running
Running,
/// Job completed successfully
Completed,
/// Job failed with error
Failed(String),
}
impl std::fmt::Display for JobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JobStatus::Pending => write!(f, "PENDING"),
JobStatus::Running => write!(f, "RUNNING"),
JobStatus::Completed => write!(f, "\u{2713} DONE"),
JobStatus::Failed(err) => write!(f, "\u{2717} FAILED: {}", err),
}
}
}
/// Progress information for a single job.
#[derive(Debug, Clone)]
pub struct JobProgress {
/// Job ID
pub job_id: String,
/// Job name/description
pub name: String,
/// Current status
pub status: JobStatus,
/// Progress percentage (0-100)
pub progress_pct: u8,
/// Optional status message
pub message: Option<String>,
/// Parent job ID (if this is a child job)
pub parent_id: Option<String>,
/// Weight for weighted progress calculation (0.0-1.0)
pub weight: f32,
}
impl JobProgress {
/// Create a new job progress tracker.
pub const fn new(job_id: String, name: String, weight: f32) -> Self {
Self {
job_id,
name,
status: JobStatus::Pending,
progress_pct: 0,
message: None,
parent_id: None,
weight,
}
}
/// Create a child job with a parent.
pub fn with_parent(mut self, parent_id: String) -> Self {
self.parent_id = Some(parent_id);
self
}
}
/// Thread-safe hierarchical progress tracker.
///
/// This tracker maintains a map of all jobs and provides methods for updating
/// progress, calculating weighted parent progress, and rendering the progress
/// tree to the terminal.
#[derive(Clone)]
pub struct ProgressTracker {
/// Map of job ID to progress information
jobs: Arc<Mutex<HashMap<String, JobProgress>>>,
}
impl ProgressTracker {
/// Create a new progress tracker.
pub fn new() -> Self {
Self {
jobs: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Add a new job to track.
///
/// # Arguments
///
/// * `job` - Job progress to track
pub async fn add_job(&self, job: JobProgress) {
let mut jobs = self.jobs.lock().await;
jobs.insert(job.job_id.clone(), job);
}
/// Update job progress.
///
/// # Arguments
///
/// * `job_id` - ID of the job to update
/// * `progress_pct` - New progress percentage (0-100)
pub async fn update_progress(&self, job_id: &str, progress_pct: u8) {
let mut jobs = self.jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.progress_pct = progress_pct.min(100);
if progress_pct >= 100 {
job.status = JobStatus::Completed;
} else if matches!(job.status, JobStatus::Pending) {
job.status = JobStatus::Running;
} else {
// Status unchanged
}
}
}
/// Update job status.
///
/// # Arguments
///
/// * `job_id` - ID of the job to update
/// * `status` - New status
pub async fn update_status(&self, job_id: &str, status: JobStatus) {
let mut jobs = self.jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.status = status;
}
}
/// Update job message.
///
/// # Arguments
///
/// * `job_id` - ID of the job to update
/// * `message` - Status message
pub async fn update_message(&self, job_id: &str, message: String) {
let mut jobs = self.jobs.lock().await;
if let Some(job) = jobs.get_mut(job_id) {
job.message = Some(message);
}
}
/// Calculate weighted progress for a parent job based on its children.
///
/// This method sums the weighted progress of all child jobs to compute
/// the parent's overall progress.
///
/// # Arguments
///
/// * `parent_id` - ID of the parent job
///
/// # Returns
///
/// Returns the weighted progress percentage (0-100).
async fn calculate_parent_progress(&self, parent_id: &str) -> u8 {
let jobs = self.jobs.lock().await;
let children: Vec<_> = jobs
.values()
.filter(|j| j.parent_id.as_deref() == Some(parent_id))
.collect();
if children.is_empty() {
return 0;
}
let weighted_sum: f32 = children
.iter()
.map(|child| (child.progress_pct as f32 / 100.0) * child.weight)
.sum();
(weighted_sum * 100.0) as u8
}
/// Render the progress tree to stdout.
///
/// This method uses ANSI escape codes to clear the screen and render
/// a hierarchical view of all jobs with their current progress.
pub async fn render(&self) -> io::Result<()> {
// Clear screen using ANSI escape codes
print!("\x1B[2J\x1B[H");
io::stdout().flush()?;
println!("\u{2554}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2557}");
println!("\u{2551} Foxhunt ML Training Progress Tracker \u{2551}");
println!("\u{255a}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{255d}");
println!();
// Collect all job data in a single lock acquisition
let jobs = self.jobs.lock().await;
let mut parent_jobs: Vec<_> = jobs
.values()
.filter(|j| j.parent_id.is_none())
.cloned()
.collect();
parent_jobs.sort_by(|a, b| a.job_id.cmp(&b.job_id));
let child_jobs: Vec<_> = jobs.values().cloned().collect();
drop(jobs); // Release lock
for parent in parent_jobs {
// Calculate parent progress from children
let parent_progress = self.calculate_parent_progress(&parent.job_id).await;
// Render parent job
println!("\u{250c}\u{2500} {} [{}%]", parent.name, parent_progress);
println!("\u{2502} Status: {}", parent.status);
if let Some(msg) = &parent.message {
println!("\u{2502} {}", msg);
}
// Find and render children
let mut children: Vec<_> = child_jobs
.iter()
.filter(|j| j.parent_id.as_deref() == Some(&parent.job_id))
.collect();
children.sort_by(|a, b| a.job_id.cmp(&b.job_id));
for (idx, child) in children.iter().enumerate() {
let is_last = idx == children.len() - 1;
let prefix = if is_last { "\u{2514}\u{2500}\u{2500}" } else { "\u{251c}\u{2500}\u{2500}" };
println!(
"\u{2502} {} {} [{}%] {} (weight: {:.0}%)",
prefix,
child.name,
child.progress_pct,
child.status,
child.weight * 100.0
);
if let Some(msg) = &child.message {
let cont = if is_last { " " } else { "\u{2502} " };
println!("\u{2502} {} {}", cont, msg);
}
}
println!();
}
Ok(())
}
/// Start a background rendering loop that updates the display every 2 seconds.
///
/// This method spawns a tokio task that continuously renders the progress
/// tree until the tracker is dropped.
///
/// # Returns
///
/// Returns a `tokio::task::JoinHandle` for the rendering task.
#[allow(clippy::infinite_loop)]
pub fn start_rendering(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(2));
loop {
interval.tick().await;
if let Err(e) = self.render().await {
eprintln!("Progress rendering error: {}", e);
}
}
})
}
}
impl Default for ProgressTracker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_progress_tracker_new() {
let tracker = ProgressTracker::new();
let jobs = tracker.jobs.lock().await;
assert!(jobs.is_empty());
}
#[tokio::test]
async fn test_add_job() {
let tracker = ProgressTracker::new();
let job = JobProgress::new(
"job1".to_string(),
"Test Job".to_string(),
1.0,
);
tracker.add_job(job).await;
let jobs = tracker.jobs.lock().await;
assert_eq!(jobs.len(), 1);
assert!(jobs.contains_key("job1"));
}
#[tokio::test]
async fn test_update_progress() {
let tracker = ProgressTracker::new();
let job = JobProgress::new(
"job1".to_string(),
"Test Job".to_string(),
1.0,
);
tracker.add_job(job).await;
tracker.update_progress("job1", 50).await;
let jobs = tracker.jobs.lock().await;
let job = jobs.get("job1").unwrap();
assert_eq!(job.progress_pct, 50);
assert_eq!(job.status, JobStatus::Running);
}
#[tokio::test]
async fn test_update_progress_completion() {
let tracker = ProgressTracker::new();
let job = JobProgress::new(
"job1".to_string(),
"Test Job".to_string(),
1.0,
);
tracker.add_job(job).await;
tracker.update_progress("job1", 100).await;
let jobs = tracker.jobs.lock().await;
let job = jobs.get("job1").unwrap();
assert_eq!(job.progress_pct, 100);
assert_eq!(job.status, JobStatus::Completed);
}
#[tokio::test]
async fn test_update_status() {
let tracker = ProgressTracker::new();
let job = JobProgress::new(
"job1".to_string(),
"Test Job".to_string(),
1.0,
);
tracker.add_job(job).await;
tracker.update_status("job1", JobStatus::Failed("Test error".to_string())).await;
let jobs = tracker.jobs.lock().await;
let job = jobs.get("job1").unwrap();
assert!(matches!(job.status, JobStatus::Failed(_)));
}
#[tokio::test]
async fn test_weighted_progress_calculation() {
let tracker = ProgressTracker::new();
// Create parent job
let parent = JobProgress::new(
"parent".to_string(),
"Batch Training".to_string(),
1.0,
);
tracker.add_job(parent).await;
// Create child jobs with different weights matching model complexity
let dqn = JobProgress::new(
"dqn".to_string(),
"DQN".to_string(),
0.10, // 10% weight
).with_parent("parent".to_string());
let ppo = JobProgress::new(
"ppo".to_string(),
"PPO".to_string(),
0.30, // 30% weight
).with_parent("parent".to_string());
let mamba2 = JobProgress::new(
"mamba2".to_string(),
"MAMBA-2".to_string(),
0.40, // 40% weight
).with_parent("parent".to_string());
let tft = JobProgress::new(
"tft".to_string(),
"TFT-INT8".to_string(),
0.20, // 20% weight
).with_parent("parent".to_string());
tracker.add_job(dqn).await;
tracker.add_job(ppo).await;
tracker.add_job(mamba2).await;
tracker.add_job(tft).await;
// Set progress: DQN 100%, PPO 50%, MAMBA-2 25%, TFT 0%
tracker.update_progress("dqn", 100).await;
tracker.update_progress("ppo", 50).await;
tracker.update_progress("mamba2", 25).await;
tracker.update_progress("tft", 0).await;
// Calculate weighted progress
// Expected: (100 * 0.10) + (50 * 0.30) + (25 * 0.40) + (0 * 0.20) = 10 + 15 + 10 + 0 = 35%
let parent_progress = tracker.calculate_parent_progress("parent").await;
assert_eq!(parent_progress, 35);
}
#[tokio::test]
async fn test_job_status_display() {
assert_eq!(JobStatus::Pending.to_string(), "PENDING");
assert_eq!(JobStatus::Running.to_string(), "RUNNING");
assert_eq!(JobStatus::Completed.to_string(), "✓ DONE");
assert_eq!(
JobStatus::Failed("error".to_string()).to_string(),
"✗ FAILED: error"
);
}
}

View File

@@ -1,194 +0,0 @@
//! fxt train start -- trigger on-demand training job
//!
//! Sends a `StartTraining` gRPC request to the ML Training Service via the API Gateway
//! and prints the resulting job ID so the user can track progress with `fxt train status`.
use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use std::collections::HashMap;
use crate::proto::ml_training::{
data_source::Source, ml_training_service_client::MlTrainingServiceClient, DataSource,
StartTrainingRequest, TrainingStatus,
};
use tonic::{metadata::MetadataValue, Request};
/// Start an on-demand training job
#[derive(Args, Debug)]
pub struct StartCommand {
/// Model type (dqn, ppo, tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion)
pub model: String,
/// Symbol to train on (e.g. ES.FUT)
pub symbol: String,
/// Number of training epochs
#[arg(long, default_value = "50")]
pub epochs: u32,
/// Use GPU acceleration
#[arg(long, default_value = "true")]
pub use_gpu: bool,
/// Optional job description
#[arg(long)]
pub description: Option<String>,
/// Data source file path (defaults to Databento cache for the symbol)
#[arg(long)]
pub data_path: Option<String>,
/// Run hyperopt (PSO hyperparameter optimization) instead of training.
/// Uses hyperopt_baseline_rl for DQN/PPO, hyperopt_baseline_supervised for others.
#[arg(long, default_value = "false")]
pub hyperopt: bool,
/// Number of PSO trials (only used with --hyperopt)
#[arg(long, default_value = "20")]
pub trials: u32,
/// Parallel trial evaluations (only with --hyperopt, 0 = auto-detect)
#[arg(long, default_value = "0")]
pub parallel: u32,
}
impl StartCommand {
/// Execute the start training command via gRPC
pub async fn run(&self, api_gateway_url: &str, jwt_token: &str) -> Result<()> {
let model_upper = self.model.to_uppercase();
validate_model_type(&model_upper)?;
println!(
"Requesting training: model={}, symbol={}, epochs={}",
model_upper.bright_magenta(),
self.symbol.bright_cyan(),
self.epochs,
);
// Connect to API Gateway
let channel = crate::client::connect_channel(api_gateway_url).await?;
let mut client = MlTrainingServiceClient::new(channel);
// Build data source -- default to Databento cache path for the symbol
let default_path = format!("data/cache/futures-baseline/{}", self.symbol);
let file_path = self
.data_path
.clone()
.unwrap_or_else(|| default_path.clone());
let data_source = DataSource {
source: Some(Source::FilePath(file_path)),
start_time: 0,
end_time: 0,
};
// Build tags
let mut tags: HashMap<String, String> = HashMap::new();
tags.insert("asset".to_owned(), self.symbol.clone());
tags.insert("source".to_owned(), "fxt-cli".to_owned());
if self.hyperopt {
tags.insert("mode".to_owned(), "hyperopt".to_owned());
tags.insert("trials".to_owned(), self.trials.to_string());
tags.insert("parallel".to_owned(), self.parallel.to_string());
}
let description = self.description.clone().unwrap_or_else(|| {
if self.hyperopt {
format!("Hyperopt {} on {} ({} trials)", model_upper, self.symbol, self.trials)
} else {
format!("On-demand {} training on {}", model_upper, self.symbol)
}
});
// Hyperparameters are left empty -- the service will apply defaults for the model type.
// Per-model params can be added later via --config flag.
let request_body = StartTrainingRequest {
model_type: model_upper.clone(),
data_source: Some(data_source),
hyperparameters: None,
use_gpu: self.use_gpu,
description,
tags,
};
// Add JWT authentication
let mut request = Request::new(request_body);
let token_value = MetadataValue::try_from(format!("Bearer {}", jwt_token))
.context("Invalid JWT token format")?;
request
.metadata_mut()
.insert("authorization", token_value);
// Call gRPC service
let response = client
.start_training(request)
.await
.context("Failed to start training job")?
.into_inner();
// Display result
let status_str = format_status(response.status);
println!("\nTraining job started: {}", response.job_id.bright_green());
println!(" Status: {}", status_str);
if self.hyperopt {
println!(" Mode: {}", "HYPEROPT".bright_yellow());
println!(" Trials: {}", self.trials);
}
println!(" Model: {}", model_upper.bright_magenta());
println!(" Symbol: {}", self.symbol.bright_cyan());
if !self.hyperopt {
println!(" Epochs: {}", self.epochs);
}
println!(" GPU: {}", self.use_gpu);
if !response.message.is_empty() {
println!(" Message: {}", response.message);
}
println!(
"\nTrack progress: fxt train status {}",
response.job_id.bright_yellow()
);
Ok(())
}
}
/// Validate that the model type is one of the supported types
fn validate_model_type(model: &str) -> Result<()> {
const VALID_MODELS: &[&str] = &[
"DQN",
"PPO",
"TFT",
"MAMBA2",
"MAMBA_2",
"TGGN",
"TLOB",
"LIQUID",
"KAN",
"XLSTM",
"DIFFUSION",
];
if VALID_MODELS.contains(&model) {
Ok(())
} else {
anyhow::bail!(
"Unsupported model type '{}'. Valid types: dqn, ppo, tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion",
model,
);
}
}
/// Format TrainingStatus enum value to display string
fn format_status(status: i32) -> String {
match TrainingStatus::try_from(status).ok() {
Some(TrainingStatus::Pending) => "PENDING".to_owned(),
Some(TrainingStatus::Running) => "RUNNING".to_owned(),
Some(TrainingStatus::Completed) => "COMPLETED".to_owned(),
Some(TrainingStatus::Failed) => "FAILED".to_owned(),
Some(TrainingStatus::Stopped) => "STOPPED".to_owned(),
Some(TrainingStatus::Paused) => "PAUSED".to_owned(),
Some(TrainingStatus::Unknown) | None => "UNKNOWN".to_owned(),
}
}

View File

@@ -1,311 +0,0 @@
//! TLI Train Status Command - Training Job Status Queries
//!
//! Provides CLI interface for querying ML training job status through the API Gateway.
use anyhow::{Context, Result as AnyhowResult};
use chrono::{DateTime, Utc};
use clap::Parser;
use colored::Colorize;
use comfy_table::{Cell, Color, Table};
// Import ML training proto types
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient, GetTrainingJobDetailsRequest,
TrainingJobDetails, TrainingStatus,
};
/// Train Status command arguments
#[derive(Parser, Debug)]
pub struct StatusCommand {
/// Training job ID to query
#[arg(value_name = "JOB_ID")]
pub job_id: String,
/// Show detailed child job breakdown (for batch jobs)
#[arg(long, short = 'v')]
pub verbose: bool,
}
impl StatusCommand {
/// Execute the train status command
pub async fn run(&self, api_gateway_url: &str, jwt_token: &str) -> AnyhowResult<()> {
println!("\u{1f50d} Fetching training job status...");
println!(" Job ID: {}", self.job_id.bright_cyan());
// Query job status from API Gateway
let job_details =
query_job_status(api_gateway_url, jwt_token, &self.job_id).await?;
// Display job summary
display_job_summary(&job_details)?;
// Display metrics (if available)
if let Some(metrics) = &job_details.final_financial_metrics {
display_financial_metrics(metrics);
}
// Display error details (if failed)
if job_details.status == TrainingStatus::Failed as i32
&& !job_details.error_message.is_empty()
{
display_error_details(&job_details.error_message);
}
// Display checkpoint path (if completed)
if job_details.status == TrainingStatus::Completed as i32
&& !job_details.model_artifact_path.is_empty()
{
display_checkpoint_info(&job_details.model_artifact_path);
}
Ok(())
}
}
/// Query job status from ML Training Service via API Gateway
pub async fn query_job_status(
api_gateway_url: &str,
jwt_token: &str,
job_id: &str,
) -> AnyhowResult<TrainingJobDetails> {
// Connect to API Gateway
let mut client = MlTrainingServiceClient::connect(api_gateway_url.to_owned())
.await
.context("Failed to connect to API Gateway")?;
// Create request with job ID
let mut request = tonic::Request::new(GetTrainingJobDetailsRequest {
job_id: job_id.to_owned(),
});
// Add JWT token to metadata
request.metadata_mut().insert(
"authorization",
format!("Bearer {}", jwt_token).parse()?,
);
// Make gRPC call
let response = client
.get_training_job_details(request)
.await
.context("Failed to get training job details")?;
let job_response = response.into_inner();
job_response
.job_details
.ok_or_else(|| anyhow::anyhow!("No job details returned from server"))
}
/// Display job summary with status, timing, and progress
fn display_job_summary(job: &TrainingJobDetails) -> AnyhowResult<()> {
println!("\n\u{1f4ca} Training Job Status");
println!("{}", "\u{2500}".repeat(80).bright_black());
// Job ID and Model
println!("Job ID: {}", job.job_id.bright_green());
println!("Model: {}", job.model_type.bright_magenta());
// Status with color coding
let status_str = format_training_status(job.status);
let status_colored = format_status_colored(&status_str);
println!("Status: {}", status_colored);
// Description (if available)
if !job.description.is_empty() {
println!("Description: {}", job.description.bright_white());
}
// Timing information
if job.started_at > 0 {
let started = DateTime::from_timestamp(job.started_at, 0)
.ok_or_else(|| anyhow::anyhow!("Invalid started_at timestamp"))?;
println!("Started: {} UTC", started.format("%Y-%m-%d %H:%M:%S"));
if job.completed_at > 0 {
let completed = DateTime::from_timestamp(job.completed_at, 0)
.ok_or_else(|| anyhow::anyhow!("Invalid completed_at timestamp"))?;
println!("Completed: {} UTC", completed.format("%Y-%m-%d %H:%M:%S"));
// Calculate duration
let duration = job.completed_at - job.started_at;
println!("Duration: {}", format_duration(duration));
} else if job.status == TrainingStatus::Running as i32 {
// Calculate elapsed time for running jobs
let now = Utc::now().timestamp();
let elapsed = now - job.started_at;
println!("Elapsed: {}", format_duration(elapsed));
} else {
// No timing info available
}
} else if job.status == TrainingStatus::Pending as i32 {
println!("Status: {}", "Waiting in queue...".yellow());
} else {
// No status display needed
}
println!("{}", "\u{2500}".repeat(80).bright_black());
Ok(())
}
/// Display financial performance metrics
fn display_financial_metrics(metrics: &crate::proto::ml_training::FinancialMetrics) {
println!("\n\u{1f3c6} Performance Metrics");
println!("{}", "\u{2500}".repeat(80).bright_black());
let mut table = Table::new();
table.set_header(vec![
Cell::new("Metric").fg(Color::Cyan),
Cell::new("Value").fg(Color::Cyan),
]);
// Sharpe Ratio
let sharpe_str = format!("{:.4}", metrics.sharpe_ratio);
let sharpe_cell = if metrics.sharpe_ratio >= 2.0 {
Cell::new(sharpe_str).fg(Color::Green)
} else if metrics.sharpe_ratio >= 1.5 {
Cell::new(sharpe_str).fg(Color::Yellow)
} else {
Cell::new(sharpe_str).fg(Color::Red)
};
table.add_row(vec![Cell::new("Sharpe Ratio"), sharpe_cell]);
// Hit Rate (Accuracy)
let hit_rate_str = format!("{:.1}%", metrics.hit_rate * 100.0);
let hit_rate_cell = if metrics.hit_rate >= 0.70 {
Cell::new(hit_rate_str).fg(Color::Green)
} else if metrics.hit_rate >= 0.60 {
Cell::new(hit_rate_str).fg(Color::Yellow)
} else {
Cell::new(hit_rate_str).fg(Color::Red)
};
table.add_row(vec![Cell::new("Hit Rate"), hit_rate_cell]);
// Simulated Return
let return_str = format!("{:+.2}%", metrics.simulated_return * 100.0);
let return_cell = if metrics.simulated_return > 0.0 {
Cell::new(return_str).fg(Color::Green)
} else {
Cell::new(return_str).fg(Color::Red)
};
table.add_row(vec![Cell::new("Simulated Return"), return_cell]);
// Max Drawdown
let drawdown_str = format!("{:.2}%", metrics.max_drawdown * 100.0);
let drawdown_cell = if metrics.max_drawdown < 0.05 {
Cell::new(drawdown_str).fg(Color::Green)
} else if metrics.max_drawdown < 0.10 {
Cell::new(drawdown_str).fg(Color::Yellow)
} else {
Cell::new(drawdown_str).fg(Color::Red)
};
table.add_row(vec![Cell::new("Max Drawdown"), drawdown_cell]);
// Risk-Adjusted Return
table.add_row(vec![
Cell::new("Risk-Adjusted Return"),
Cell::new(format!("{:+.2}%", metrics.risk_adjusted_return * 100.0)),
]);
// VaR 5%
table.add_row(vec![
Cell::new("VaR (5%)"),
Cell::new(format!("{:.2}%", metrics.var_5pct * 100.0)),
]);
println!("{}", table);
}
/// Display error details for failed jobs
fn display_error_details(error_message: &str) {
println!("\n\u{274c} Error Details");
println!("{}", "\u{2500}".repeat(80).bright_black());
println!("{}", error_message.red());
println!("{}", "\u{2500}".repeat(80).bright_black());
}
/// Display checkpoint path for completed jobs
fn display_checkpoint_info(checkpoint_path: &str) {
println!("\n\u{1f4be} Model Checkpoint");
println!("{}", "\u{2500}".repeat(80).bright_black());
println!("Path: {}", checkpoint_path.bright_green());
// Estimate file size (if path exists)
if let Ok(metadata) = std::fs::metadata(checkpoint_path) {
let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
println!("Size: {:.1} MB", size_mb);
}
println!("{}", "\u{2500}".repeat(80).bright_black());
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Convert `TrainingStatus` enum to string
pub fn format_training_status(status: i32) -> String {
match TrainingStatus::try_from(status).ok() {
Some(TrainingStatus::Unknown) => "UNKNOWN".to_owned(),
Some(TrainingStatus::Pending) => "PENDING".to_owned(),
Some(TrainingStatus::Running) => "RUNNING".to_owned(),
Some(TrainingStatus::Completed) => "COMPLETED".to_owned(),
Some(TrainingStatus::Failed) => "FAILED".to_owned(),
Some(TrainingStatus::Stopped) => "STOPPED".to_owned(),
Some(TrainingStatus::Paused) => "PAUSED".to_owned(),
None => format!("UNKNOWN({})", status),
}
}
/// Format status with color coding
fn format_status_colored(status: &str) -> colored::ColoredString {
match status {
"RUNNING" => format!("\u{23f3} {}", status).green(),
"COMPLETED" => format!("\u{2705} {}", status).bright_green().bold(),
"FAILED" => format!("\u{274c} {}", status).red().bold(),
"STOPPED" => format!("\u{1f6d1} {}", status).yellow(),
"PENDING" => format!("\u{23f8}\u{fe0f} {}", status).bright_blue(),
"PAUSED" => format!("\u{23f8}\u{fe0f} {}", status).yellow(),
_ => status.white(),
}
}
/// Format duration in human-readable format
pub fn format_duration(seconds: i64) -> String {
let abs = seconds.unsigned_abs();
let hours = abs / 3600;
let minutes = (abs % 3600) / 60;
let secs = abs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, secs)
} else if minutes > 0 {
format!("{}m {}s", minutes, secs)
} else {
format!("{}s", secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_training_status_enum() {
assert_eq!(format_training_status(TrainingStatus::Pending as i32), "PENDING");
assert_eq!(format_training_status(TrainingStatus::Running as i32), "RUNNING");
assert_eq!(format_training_status(TrainingStatus::Completed as i32), "COMPLETED");
assert_eq!(format_training_status(TrainingStatus::Failed as i32), "FAILED");
assert_eq!(format_training_status(TrainingStatus::Stopped as i32), "STOPPED");
assert_eq!(format_training_status(TrainingStatus::Paused as i32), "PAUSED");
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(30), "30s");
assert_eq!(format_duration(90), "1m 30s");
assert_eq!(format_duration(204), "3m 24s");
assert_eq!(format_duration(3661), "1h 1m 1s");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,377 +0,0 @@
//! Implementation for tune status and tune best commands with real gRPC integration
use anyhow::{Context, Result as AnyhowResult};
use chrono;
use colored::Colorize;
use std::collections::HashMap;
use tabled::{Table, Tabled};
use uuid::Uuid;
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient,
GetTuningJobStatusRequest,
TuningJobStatus, TrialState,
};
/// Best hyperparameters display (for table output)
#[derive(Debug, Clone, Tabled)]
struct BestParamDisplay {
#[tabled(rename = "Parameter")]
name: String,
#[tabled(rename = "Value")]
value: String,
#[tabled(rename = "Type")]
param_type: String,
}
/// Trial result display (for history table)
#[derive(Debug, Clone, Tabled)]
struct TrialDisplay {
#[tabled(rename = "Trial")]
trial_number: u32,
#[tabled(rename = "Sharpe Ratio")]
sharpe_ratio: String,
#[tabled(rename = "State")]
state: String,
#[tabled(rename = "Duration (s)")]
duration: i64,
}
/// Get tuning job status with rich progress display
pub async fn get_tuning_status(
api_gateway_url: &str,
jwt_token: &str,
job_id_str: &str,
) -> AnyhowResult<()> {
// Validate job ID format
let job_id = Uuid::parse_str(job_id_str)
.context("❌ Invalid job ID format (expected UUID)")?;
println!("🔍 Fetching tuning job status...");
println!(" Job ID: {}", job_id.to_string().bright_cyan());
// Create gRPC client
let mut client = MlTrainingServiceClient::connect(api_gateway_url.to_string())
.await
.context("Failed to connect to API Gateway")?;
// Create request with JWT metadata
let mut request = tonic::Request::new(GetTuningJobStatusRequest {
job_id: job_id.to_string(),
});
request.metadata_mut().insert(
"authorization",
format!("Bearer {}", jwt_token)
.parse()
.context("Failed to parse JWT token")?
);
// Execute gRPC call
let response = client
.get_tuning_job_status(request)
.await
.context("Failed to get tuning job status")?;
let status_response = response.into_inner();
// Calculate progress percentage and elapsed time
let progress_percent = if status_response.total_trials > 0 {
(status_response.current_trial as f32 / status_response.total_trials as f32) * 100.0
} else {
0.0
};
let elapsed_seconds = if status_response.started_at > 0 {
chrono::Utc::now().timestamp() - status_response.started_at
} else {
0
};
let status_str = format_tuning_status(status_response.status());
// Display status with color coding
println!("\n📊 Tuning Job Status");
println!(" Job ID: {}", job_id.to_string().bright_cyan());
println!(" Status: {}", format_status_colored(&status_str));
println!(" Progress: {}/{} trials ({:.1}%)",
status_response.current_trial,
status_response.total_trials,
progress_percent
);
// Progress bar visualization
let progress_bar = create_progress_bar(progress_percent);
println!(" {}", progress_bar);
// Display best results if available
if !status_response.best_metrics.is_empty() {
println!("\n🏆 Best Results So Far");
// Extract Sharpe ratio from best_metrics
if let Some(sharpe_ratio) = status_response.best_metrics.get("sharpe_ratio") {
println!(" Best Sharpe Ratio: {}", format!("{:.4}", sharpe_ratio).bright_green());
}
// Find which trial achieved the best result
if let Some(best_trial) = status_response.trial_history.iter()
.filter(|t| t.state() == TrialState::TrialComplete)
.max_by(|a, b| a.objective_value.partial_cmp(&b.objective_value).unwrap_or(std::cmp::Ordering::Equal))
{
println!(" Best Trial: #{}", best_trial.trial_number.to_string().bright_yellow());
}
}
// Display elapsed time
let elapsed_minutes = elapsed_seconds / 60;
let elapsed_seconds_remainder = elapsed_seconds % 60;
// Estimate remaining time if in progress
if status_response.status() == TuningJobStatus::TuningRunning && status_response.current_trial > 0 {
let avg_trial_time = elapsed_seconds as f64 / status_response.current_trial as f64;
let remaining_trials = status_response.total_trials.saturating_sub(status_response.current_trial);
let est_remaining_seconds = (avg_trial_time * remaining_trials as f64) as i64;
let est_remaining_minutes = est_remaining_seconds / 60;
println!("\n⏱️ Time Information");
println!(" Elapsed Time: {}m {}s", elapsed_minutes, elapsed_seconds_remainder);
println!(" Estimated Time Remaining: {} minutes", est_remaining_minutes);
} else {
println!("\n⏱️ Elapsed Time: {}m {}s", elapsed_minutes, elapsed_seconds_remainder);
}
// Display trial history if available
if !status_response.trial_history.is_empty() {
display_trial_history(&status_response.trial_history)?;
}
// Display message if available
if !status_response.message.is_empty() {
println!("\n💬 Message: {}", status_response.message);
}
Ok(())
}
/// Get best hyperparameters found so far
pub async fn get_best_params(
api_gateway_url: &str,
jwt_token: &str,
job_id_str: &str,
export_path: Option<&str>,
) -> AnyhowResult<()> {
// Validate job ID
let job_id = Uuid::parse_str(job_id_str)
.context("❌ Invalid job ID format (expected UUID)")?;
println!("🔍 Fetching best hyperparameters...");
println!(" Job ID: {}", job_id.to_string().bright_cyan());
// Create gRPC client
let mut client = MlTrainingServiceClient::connect(api_gateway_url.to_string())
.await
.context("Failed to connect to API Gateway")?;
// Create request with JWT metadata
let mut request = tonic::Request::new(GetTuningJobStatusRequest {
job_id: job_id.to_string(),
});
request.metadata_mut().insert(
"authorization",
format!("Bearer {}", jwt_token)
.parse()
.context("Failed to parse JWT token")?
);
// Execute gRPC call
let response = client
.get_tuning_job_status(request)
.await
.context("Failed to get tuning job status")?;
let status_response = response.into_inner();
// Display best metrics
if !status_response.best_metrics.is_empty() {
println!("\n🏆 Best Performance Metrics");
// Extract and display Sharpe ratio prominently
if let Some(sharpe_ratio) = status_response.best_metrics.get("sharpe_ratio") {
println!(" Sharpe Ratio: {}", format!("{:.4}", sharpe_ratio).bright_green().bold());
}
// Display other metrics
for (metric_name, metric_value) in &status_response.best_metrics {
if metric_name != "sharpe_ratio" {
println!(" {}: {}",
metric_name.bright_white(),
format!("{:.6}", metric_value).bright_green()
);
}
}
}
// Display best hyperparameters as table
if !status_response.best_params.is_empty() {
println!("\n📋 Best Hyperparameters");
let param_rows: Vec<BestParamDisplay> = status_response.best_params
.iter()
.map(|(name, value)| BestParamDisplay {
name: name.clone(),
value: format!("{:.6}", value),
param_type: infer_param_type(*value),
})
.collect();
let table = Table::new(param_rows).to_string();
println!("{}", table);
// Display checkpoint path if available
println!("\n💾 Model Checkpoint");
println!(" (Checkpoint location will be provided after training completes)");
} else {
println!("\n⚠️ No best parameters available yet");
println!(" Job may still be initializing or no trials have completed successfully");
}
// Export to file if requested
if let Some(export_path) = export_path {
if !status_response.best_params.is_empty() {
export_best_params(
&status_response.best_params,
&status_response.best_metrics,
export_path
)?;
println!("\n✅ Best parameters exported to: {}", export_path.bright_green());
} else {
println!("\n⚠️ Cannot export: no parameters available yet");
}
}
println!("\n💡 Use these parameters in your training configuration.");
Ok(())
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Display trial history table
fn display_trial_history(trial_history: &[crate::proto::ml_training::TrialResult]) -> AnyhowResult<()> {
println!("\n📊 Trial History (last 10 trials)");
// Take last 10 trials
let recent_trials: Vec<TrialDisplay> = trial_history
.iter()
.rev()
.take(10)
.rev()
.map(|trial| {
let duration_seconds = if trial.completed_at > trial.started_at {
trial.completed_at - trial.started_at
} else {
0
};
TrialDisplay {
trial_number: trial.trial_number,
sharpe_ratio: format!("{:.4}", trial.objective_value),
state: format_trial_state(trial.state()),
duration: duration_seconds,
}
})
.collect();
let table = Table::new(recent_trials).to_string();
println!("{}", table);
Ok(())
}
/// Format tuning job status as string
fn format_tuning_status(status: TuningJobStatus) -> String {
match status {
TuningJobStatus::TuningUnknown => "UNKNOWN",
TuningJobStatus::TuningPending => "PENDING",
TuningJobStatus::TuningRunning => "RUNNING",
TuningJobStatus::TuningCompleted => "COMPLETED",
TuningJobStatus::TuningFailed => "FAILED",
TuningJobStatus::TuningStopped => "STOPPED",
}.to_string()
}
/// Format trial state as string
fn format_trial_state(state: TrialState) -> String {
match state {
TrialState::TrialUnknown => "UNKNOWN",
TrialState::TrialRunning => "RUNNING",
TrialState::TrialComplete => "COMPLETE",
TrialState::TrialPruned => "PRUNED",
TrialState::TrialFailed => "FAILED",
}.to_string()
}
/// Format status with color coding
fn format_status_colored(status: &str) -> colored::ColoredString {
match status {
"RUNNING" | "TUNING_RUNNING" => status.green(),
"COMPLETED" | "TUNING_COMPLETED" => status.bright_green().bold(),
"FAILED" | "TUNING_FAILED" => status.red().bold(),
"STOPPED" | "TUNING_STOPPED" => status.yellow(),
"PENDING" | "TUNING_PENDING" => status.bright_blue(),
_ => status.white(),
}
}
/// Create ASCII progress bar
fn create_progress_bar(progress_percent: f32) -> String {
let bar_width = 50;
let filled = ((progress_percent / 100.0) * bar_width as f32) as usize;
let empty = bar_width.saturating_sub(filled);
let filled_str = "".repeat(filled).green();
let empty_str = "".repeat(empty).white();
format!("[{}{}] {:.1}%", filled_str, empty_str, progress_percent)
}
/// Infer parameter type from value
fn infer_param_type(value: f32) -> String {
if value == value.floor() && value >= 1.0 && value <= 10000.0 {
"Integer".to_string()
} else if value > 0.0 && value < 1.0 {
"Learning Rate".to_string()
} else {
"Float".to_string()
}
}
/// Export best parameters to YAML file
fn export_best_params(
params: &HashMap<String, f32>,
metrics: &HashMap<String, f32>,
export_path: &str,
) -> AnyhowResult<()> {
use std::io::Write;
let mut content = String::from("# Best Hyperparameters from Tuning Job\n\n");
content.push_str("hyperparameters:\n");
for (name, value) in params {
content.push_str(&format!(" {}: {}\n", name, value));
}
content.push_str("\nmetrics:\n");
for (name, value) in metrics {
content.push_str(&format!(" {}: {:.6}\n", name, value));
}
let mut file = std::fs::File::create(export_path)
.context("Failed to create export file")?;
file.write_all(content.as_bytes())
.context("Failed to write to export file")?;
Ok(())
}

View File

@@ -1,257 +0,0 @@
//! TLI Streaming Client for Hyperparameter Tuning Progress
//!
//! This module implements real-time streaming progress updates for tuning jobs.
//! Uses gRPC streaming to receive trial completion notifications from ML Training Service.
use anyhow::{Context, Result as AnyhowResult};
use colored::Colorize;
use tokio_stream::StreamExt;
use tracing::{debug, error, info};
use uuid::Uuid;
use crate::proto::ml_training::{
ml_training_service_client::MlTrainingServiceClient,
StreamProgressRequest, UpdateType,
};
/// Watch tuning progress with real-time streaming updates
pub async fn watch_tuning_progress_streaming(
api_gateway_url: &str,
jwt_token: &str,
job_id: &str,
) -> AnyhowResult<()> {
// Validate job ID format
let job_id_uuid = Uuid::parse_str(job_id)
.context("\u{274c} Invalid job ID format (expected UUID)")?;
info!("Subscribing to tuning progress stream for job: {}", job_id_uuid);
// Create gRPC client with reconnect capability
let mut client = MlTrainingServiceClient::connect(api_gateway_url.to_owned())
.await
.context("Failed to connect to API Gateway")?;
// Create streaming request with JWT metadata
let mut request = tonic::Request::new(StreamProgressRequest {
job_id: job_id.to_owned(),
});
request.metadata_mut().insert(
"authorization",
format!("Bearer {}", jwt_token)
.parse()
.context("Failed to parse JWT token")?
);
// Subscribe to progress stream
let response = client
.stream_tuning_progress(request)
.await
.context("Failed to subscribe to progress stream")?;
let mut stream = response.into_inner();
println!("\n\u{1f440} Watching tuning progress (press Ctrl+C to stop)...\n");
// Track progress state
let mut last_displayed_trial = 0_u32;
let mut iteration = 0_u32;
// Process stream updates
while let Some(result) = stream.next().await {
match result {
Ok(update) => {
iteration += 1;
let update_type = UpdateType::try_from(update.update_type)
.unwrap_or(UpdateType::UpdateUnknown);
// Skip heartbeats unless we want to show keepalive
if update_type == UpdateType::UpdateHeartbeat {
debug!("Received heartbeat for job {}", job_id);
continue;
}
// Clear previous display if not first iteration
if iteration > 1 && update.current_trial != last_displayed_trial {
// Clear previous display (9 lines)
print!("\x1B[9A\x1B[J");
}
// Display rich progress UI
display_progress_ui(&update);
last_displayed_trial = update.current_trial;
// Check if job is complete
if update_type == UpdateType::UpdateJobComplete {
let status_str = format_status_from_i32(update.status);
match status_str.as_str() {
"TUNING_COMPLETED" => {
println!("\n\u{2705} Tuning job completed successfully!");
println!(" Best Sharpe Ratio: {}", format!("{:.4}", update.best_sharpe_so_far).bright_green());
println!("\n\u{1f4a1} Get best parameters with:");
println!(" tli tune best --job-id {}", job_id);
}
"TUNING_FAILED" => {
println!("\n\u{274c} Tuning job failed!");
println!(" Message: {}", update.message);
}
"TUNING_STOPPED" => {
println!("\n\u{1f6d1} Tuning job stopped by user");
println!("\n\u{1f4a1} Get partial results with:");
println!(" tli tune best --job-id {}", job_id);
}
_ => {
println!("\n\u{26a0}\u{fe0f} Tuning job ended with status: {}", status_str);
}
}
break;
}
}
Err(e) => {
error!("Stream error: {}", e);
println!("\n\u{26a0}\u{fe0f} Stream error: {}", e);
println!(" Connection lost, attempting to reconnect...");
// Try to reconnect with exponential backoff
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
// Attempt reconnect (caller should handle retry logic)
return Err(anyhow::anyhow!("Stream disconnected: {}", e));
}
}
}
println!("\n\u{1f4ca} Progress stream ended");
Ok(())
}
/// Display rich progress UI for tuning job
fn display_progress_ui(update: &crate::proto::ml_training::ProgressUpdate) {
let progress_percent = if update.total_trials > 0 {
(update.current_trial as f32 / update.total_trials as f32) * 100.0
} else {
0.0
};
let status_str = format_status_from_i32(update.status);
println!("\u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}");
println!("\u{2502} \u{1f3af} Tuning Job: {} \u{2502}",
update.job_id.chars().take(8).collect::<String>()
);
println!("\u{251c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2524}");
println!("\u{2502} Progress: {}/{} ({:.1}%) \u{2502}",
update.current_trial,
update.total_trials,
progress_percent
);
// Progress bar
let progress_bar = create_progress_bar(progress_percent);
println!("\u{2502} {} \u{2502}", progress_bar);
println!("\u{2502} \u{1f3c6} Best Sharpe Ratio: {} \u{2502}",
format!("{:.4}", update.best_sharpe_so_far).bright_green()
);
println!("\u{2502} \u{1f4c8} Trial Sharpe: {} \u{2502}",
format!("{:.4}", update.trial_sharpe).bright_cyan()
);
// Time remaining
if update.estimated_time_remaining > 0 {
let remaining_minutes = update.estimated_time_remaining / 60;
let remaining_seconds = update.estimated_time_remaining % 60;
println!("\u{2502} \u{23f1}\u{fe0f} Estimated Time: {}m {}s remaining \u{2502}",
remaining_minutes, remaining_seconds
);
} else {
println!("\u{2502} \u{23f1}\u{fe0f} Estimated Time: calculating... \u{2502}");
}
println!("\u{2502} \u{1f4ca} Status: {} \u{2502}",
format_status_colored(&status_str)
);
// Trial parameters (show first 3)
if !update.trial_params.is_empty() {
let param_count = update.trial_params.len().min(3);
let params_display: Vec<String> = update.trial_params
.iter()
.take(param_count)
.map(|(k, v)| format!("{}={}", k, v))
.collect();
let params_str = params_display.join(", ");
println!("\u{2502} \u{1f527} Params: {} \u{2502}", params_str.chars().take(43).collect::<String>());
}
println!("\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}");
}
/// Create ASCII progress bar
fn create_progress_bar(progress_percent: f32) -> String {
let bar_width: usize = 50;
let filled = ((progress_percent / 100.0) * bar_width as f32) as usize;
let empty = bar_width.saturating_sub(filled);
let filled_str = "\u{2588}".repeat(filled).green();
let empty_str = "\u{2591}".repeat(empty).white();
format!("[{}{}] {:.1}%", filled_str, empty_str, progress_percent)
}
/// Format status with color coding
fn format_status_colored(status: &str) -> colored::ColoredString {
match status {
"TUNING_RUNNING" => status.green(),
"TUNING_COMPLETED" => status.bright_green().bold(),
"TUNING_FAILED" => status.red().bold(),
"TUNING_STOPPED" => status.yellow(),
"TUNING_PENDING" => status.bright_blue(),
_ => status.white(),
}
}
/// Convert i32 status to string
fn format_status_from_i32(status: i32) -> String {
match status {
0 => "TUNING_UNKNOWN",
1 => "TUNING_PENDING",
2 => "TUNING_RUNNING",
3 => "TUNING_COMPLETED",
4 => "TUNING_FAILED",
5 => "TUNING_STOPPED",
_ => "TUNING_UNKNOWN",
}.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_bar_generation() {
let bar_0 = create_progress_bar(0.0);
assert!(bar_0.contains("0.0%"));
let bar_50 = create_progress_bar(50.0);
assert!(bar_50.contains("50.0%"));
let bar_100 = create_progress_bar(100.0);
assert!(bar_100.contains("100.0%"));
}
#[test]
fn test_status_formatting() {
let status = format_status_from_i32(2);
assert_eq!(status, "TUNING_RUNNING");
let status = format_status_from_i32(3);
assert_eq!(status, "TUNING_COMPLETED");
let status = format_status_from_i32(4);
assert_eq!(status, "TUNING_FAILED");
}
}

View File

@@ -0,0 +1,17 @@
//! `fxt watch` -- TUI cockpit dashboard.
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
pub struct WatchCommand;
impl WatchCommand {
/// Launch the full-screen TUI dashboard.
///
/// This command does not need a gRPC client at construction time --
/// the TUI event loop manages its own connections.
pub async fn execute(&self) -> Result<()> {
anyhow::bail!("watch (TUI) not yet implemented")
}
}

View File

@@ -1,852 +0,0 @@
//! Event loop for the `fxt watch` streaming TUI dashboard.
//!
//! [`run_dashboard`] is the public entry point that sets up the terminal,
//! runs the select-loop, and guarantees clean-up on exit or error.
use anyhow::{Context, Result};
use crossterm::{
event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use std::io;
use tokio::time::{interval, Duration};
use tokio_stream::StreamExt;
use super::render;
use super::state::{
CircuitBreakerRow, DashboardState, FillRow, ServiceRow, Tab, TrainingViewMode,
};
use super::streams::{self, StreamEvent};
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Initialise the terminal, run the event loop, and restore the terminal on
/// exit (even if an error occurred).
pub(super) async fn run_dashboard(api_gateway_url: &str, jwt_token: &str) -> Result<()> {
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal =
Terminal::new(backend).context("failed to create ratatui terminal")?;
let result = run_event_loop(&mut terminal, api_gateway_url, jwt_token).await;
// Always restore the terminal, regardless of whether the loop succeeded.
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result
}
// ---------------------------------------------------------------------------
// Core event loop
// ---------------------------------------------------------------------------
async fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
api_gateway_url: &str,
jwt_token: &str,
) -> Result<()> {
let mut state = DashboardState {
dirty: true,
..DashboardState::default()
};
let mut stream_rx = streams::spawn_all_streams(api_gateway_url, jwt_token);
let mut tick = interval(Duration::from_millis(200));
let mut event_stream = EventStream::new();
loop {
tokio::select! {
maybe_event = stream_rx.recv() => {
if let Some(event) = maybe_event {
apply_stream_event(&mut state, event);
}
// When None, all stream producers dropped — keep running so
// the user can still view stale data and quit manually.
}
maybe_crossterm = event_stream.next() => {
if let Some(Ok(Event::Key(key))) = maybe_crossterm {
if handle_key(&mut state, key) {
return Ok(());
}
}
}
_ = tick.tick() => {
if state.dirty {
terminal
.draw(|f| render::render(f, &state))
.context("failed to draw frame")?;
state.dirty = false;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Stream event application
// ---------------------------------------------------------------------------
fn apply_stream_event(state: &mut DashboardState, event: StreamEvent) {
match event {
StreamEvent::TrainingUpdate { sessions, gpu } => {
// Filter out uninitialised or aggregate rows:
// - empty model name (default proto)
// - model present but empty fold with zero epoch (summary row)
let active_sessions: Vec<_> = sessions
.into_iter()
.filter(|s| !(s.model.is_empty() || s.fold.is_empty() && s.epoch == 0.0))
.collect();
// Push first session's epoch_loss to global loss sparkline.
if let Some(first) = active_sessions.first() {
state.training.push_loss(f64::from(first.epoch_loss));
}
// Accumulate per-session metric history for detail sparklines.
for session in &active_sessions {
let key = format!("{}:{}", session.model, session.fold);
state
.training
.session_histories
.entry(key)
.or_default()
.push(session);
}
state.training.sessions = active_sessions;
state.training.clamp_selection();
state.training.gpu = gpu.clone();
state.system.gpu = gpu;
state.training.connected = true;
}
StreamEvent::OrderUpdate {
order_id: _,
symbol,
status,
filled_qty,
last_fill_price,
timestamp_nanos,
} => {
let fill = FillRow {
time: format_nanos(timestamp_nanos),
side: if filled_qty >= 0.0 {
"BUY".to_owned()
} else {
"SELL".to_owned()
},
symbol,
quantity: filled_qty.abs(),
price: last_fill_price,
status,
};
state.trading.push_fill(fill);
state.trading.connected = true;
}
StreamEvent::PositionsSnapshot { positions } => {
let unrealized: f64 = positions.iter().map(|p| p.unrealized_pnl).sum();
state.trading.positions = positions;
state.trading.unrealized_pnl = unrealized;
state.trading.connected = true;
}
StreamEvent::RiskMetrics {
var,
max_drawdown,
current_drawdown,
sharpe,
} => {
state.risk.portfolio_var = var;
state.risk.max_drawdown = max_drawdown;
state.risk.current_drawdown = current_drawdown;
state.risk.sharpe_ratio = sharpe;
state.risk.connected = true;
}
StreamEvent::RiskAlert {
severity: _,
symbol: _,
message,
threshold,
current,
} => {
let status = if current < threshold {
"OK".to_owned()
} else {
"TRIPPED".to_owned()
};
// Upsert: find existing breaker by name or insert new.
if let Some(cb) = state
.risk
.circuit_breakers
.iter_mut()
.find(|cb| cb.name == message)
{
cb.status = status;
cb.current = format!("{current:.4}");
cb.limit = format!("{threshold:.4}");
} else {
state.risk.circuit_breakers.push(CircuitBreakerRow {
name: message,
status,
current: format!("{current:.4}"),
limit: format!("{threshold:.4}"),
});
}
state.risk.connected = true;
}
StreamEvent::SystemStatus {
service,
status,
message,
} => {
// Upsert: find existing service by name or insert new.
if let Some(svc) = state
.system
.services
.iter_mut()
.find(|s| s.name == service)
{
svc.status = status;
svc.message = message;
} else {
state.system.services.push(ServiceRow {
name: service,
status,
message,
latency_ms: 0.0,
});
}
state.system.connected = true;
}
StreamEvent::StreamDisconnected { stream_name } => {
match stream_name.as_str() {
"training" | "training_metrics" => state.training.connected = false,
"trading" | "orders" | "order_updates" => state.trading.connected = false,
"positions" => state.trading.connected = false,
"risk" | "risk_alerts" => state.risk.connected = false,
"system" | "system_status" => state.system.connected = false,
_ => {}
}
}
}
state.dirty = true;
}
// ---------------------------------------------------------------------------
// Key handling
// ---------------------------------------------------------------------------
/// Returns `true` if the user wants to quit.
#[allow(clippy::wildcard_enum_match_arm)] // KeyCode has 25+ variants; exhaustive match is noise
fn handle_key(state: &mut DashboardState, key: KeyEvent) -> bool {
// Global quit
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return true,
_ => {}
}
// Training tab has special modal key handling.
if state.current_tab() == Tab::Training {
match state.training.view_mode {
TrainingViewMode::List => match key.code {
KeyCode::Char('j') | KeyCode::Down => {
state.training.select_down();
state.dirty = true;
}
KeyCode::Char('k') | KeyCode::Up => {
state.training.select_up();
state.dirty = true;
}
KeyCode::Enter => {
state.training.enter_detail();
state.dirty = true;
}
KeyCode::Char('1') => state.select_tab(0),
KeyCode::Char('2') => state.select_tab(1),
KeyCode::Char('3') => state.select_tab(2),
KeyCode::Char('4') => state.select_tab(3),
_ => {}
},
TrainingViewMode::Detail => match key.code {
KeyCode::Esc => {
state.training.exit_detail();
state.dirty = true;
}
KeyCode::Tab => {
state.training.next_sub_tab();
state.dirty = true;
}
KeyCode::BackTab => {
state.training.prev_sub_tab();
state.dirty = true;
}
KeyCode::Char('l') | KeyCode::Right => {
// Next session
state.training.select_down();
state.dirty = true;
}
KeyCode::Char('h') | KeyCode::Left => {
// Previous session
state.training.select_up();
state.dirty = true;
}
KeyCode::Char('j') | KeyCode::Down => {
state.training.scroll_down();
state.dirty = true;
}
KeyCode::Char('k') | KeyCode::Up => {
state.training.scroll_up();
state.dirty = true;
}
KeyCode::Char(c @ '1'..='4') => {
state.training.exit_detail();
state.select_tab((c as u8 - b'1') as usize);
}
_ => {}
},
}
return false;
}
// Non-Training tabs: standard key handling.
match key.code {
KeyCode::Char('1') => state.select_tab(0),
KeyCode::Char('2') => state.select_tab(1),
KeyCode::Char('3') => state.select_tab(2),
KeyCode::Char('4') => state.select_tab(3),
KeyCode::Char('j') | KeyCode::Down => state.scroll_down(),
KeyCode::Char('k') | KeyCode::Up => state.scroll_up(),
_ => {}
}
false
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Format a nanosecond Unix timestamp as `HH:MM:SS`.
#[allow(clippy::modulo_arithmetic)] // Intentional clock arithmetic on non-negative quotients
fn format_nanos(nanos: i64) -> String {
let secs = nanos / 1_000_000_000;
let hour = (secs / 3600).rem_euclid(24);
let min = (secs / 60).rem_euclid(60);
let sec = secs.rem_euclid(60);
format!("{hour:02}:{min:02}:{sec:02}")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use super::super::state::GpuInfo;
use super::super::state::PositionRow;
use super::super::state::TrainingSession;
use crossterm::event::KeyEvent;
// -- Key handling --------------------------------------------------------
#[test]
fn test_handle_key_quit() {
let mut state = DashboardState::default();
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert!(handle_key(&mut state, key));
}
#[test]
fn test_handle_key_ctrl_c_quit() {
let mut state = DashboardState::default();
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(handle_key(&mut state, key));
}
#[test]
fn test_handle_key_tab_switch() {
let mut state = DashboardState::default();
let key = KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE);
assert!(!handle_key(&mut state, key));
assert_eq!(state.active_tab, 2);
}
#[test]
fn test_handle_key_scroll_non_training_tab() {
// Non-training tabs still use scroll on j/k.
let mut state = DashboardState::default();
state.select_tab(1); // Trading tab
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
handle_key(&mut state, j);
assert_eq!(state.trading.scroll_offset, 1);
let k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
handle_key(&mut state, k);
assert_eq!(state.trading.scroll_offset, 0);
}
#[test]
fn test_handle_key_arrow_scroll_non_training_tab() {
let mut state = DashboardState::default();
state.select_tab(1); // Trading tab
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
handle_key(&mut state, down);
assert_eq!(state.trading.scroll_offset, 1);
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
handle_key(&mut state, up);
assert_eq!(state.trading.scroll_offset, 0);
}
#[test]
fn test_handle_key_non_quit_returns_false() {
let mut state = DashboardState::default();
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
assert!(!handle_key(&mut state, key));
}
// -- Training list mode keys -------------------------------------------
fn make_state_with_sessions(n: usize) -> DashboardState {
let mut state = DashboardState::default();
for i in 0..n {
state.training.sessions.push(TrainingSession {
model: format!("DQN_{i}"),
fold: format!("fold_{i}"),
..Default::default()
});
}
state
}
#[test]
fn test_training_list_jk_selects() {
let mut state = make_state_with_sessions(3);
// j selects first row
let j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
handle_key(&mut state, j);
assert_eq!(state.training.selected_index, Some(0));
// j again moves to second
handle_key(&mut state, j);
assert_eq!(state.training.selected_index, Some(1));
// k moves back
let k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
handle_key(&mut state, k);
assert_eq!(state.training.selected_index, Some(0));
}
#[test]
fn test_training_list_enter_opens_detail() {
let mut state = make_state_with_sessions(3);
state.training.selected_index = Some(1);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
handle_key(&mut state, enter);
assert_eq!(state.training.view_mode, TrainingViewMode::Detail);
}
// -- Training detail mode keys -----------------------------------------
#[test]
fn test_training_detail_esc_exits() {
let mut state = make_state_with_sessions(3);
state.training.selected_index = Some(0);
state.training.enter_detail();
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
handle_key(&mut state, esc);
assert_eq!(state.training.view_mode, TrainingViewMode::List);
}
#[test]
fn test_training_detail_tab_cycles_sub_tabs() {
let mut state = make_state_with_sessions(1);
state.training.selected_index = Some(0);
state.training.enter_detail();
// DQN_0 -> Overview, Loss, RL, Health
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
handle_key(&mut state, tab);
assert_eq!(
state.training.detail_sub_tab,
super::super::state::DetailSubTab::Loss
);
}
#[test]
fn test_training_detail_backtab_cycles_back() {
let mut state = make_state_with_sessions(1);
state.training.selected_index = Some(0);
state.training.enter_detail();
state.training.detail_sub_tab = super::super::state::DetailSubTab::Loss;
let backtab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT);
handle_key(&mut state, backtab);
assert_eq!(
state.training.detail_sub_tab,
super::super::state::DetailSubTab::Overview
);
}
#[test]
fn test_training_detail_lr_navigates_sessions() {
let mut state = make_state_with_sessions(3);
state.training.selected_index = Some(0);
state.training.enter_detail();
// Right moves to next session
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
handle_key(&mut state, right);
assert_eq!(state.training.selected_index, Some(1));
// Left moves back
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
handle_key(&mut state, left);
assert_eq!(state.training.selected_index, Some(0));
}
#[test]
fn test_training_detail_number_exits_and_switches_tab() {
let mut state = make_state_with_sessions(3);
state.training.selected_index = Some(0);
state.training.enter_detail();
let key2 = KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE);
handle_key(&mut state, key2);
assert_eq!(state.training.view_mode, TrainingViewMode::List);
assert_eq!(state.active_tab, 1); // Trading tab
}
#[test]
fn test_training_detail_q_quits() {
let mut state = make_state_with_sessions(1);
state.training.selected_index = Some(0);
state.training.enter_detail();
let q = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert!(handle_key(&mut state, q));
}
// -- format_nanos --------------------------------------------------------
#[test]
fn test_format_nanos() {
assert_eq!(format_nanos(52_321_000_000_000), "14:32:01");
}
#[test]
fn test_format_nanos_zero() {
assert_eq!(format_nanos(0), "00:00:00");
}
#[test]
fn test_format_nanos_midnight() {
// Exactly 24 hours in nanos wraps to 00:00:00.
let nanos = 24 * 3600 * 1_000_000_000_i64;
assert_eq!(format_nanos(nanos), "00:00:00");
}
// -- apply_stream_event --------------------------------------------------
#[test]
fn test_apply_training_update() {
let mut state = DashboardState::default();
let session = TrainingSession {
model: "DQN".into(),
fold: "fold0".into(),
is_hyperopt: false,
epoch: 5.0,
epoch_loss: 0.123,
validation_loss: 0.456,
learning_rate: 1e-3,
gpu_percent: 85.0,
batches_per_second: 42.0,
gradient_norm: 1.5,
epoch_duration_seconds: 12.3,
..Default::default()
};
let gpu = GpuInfo {
utilization_percent: 85.0,
memory_used_mb: 3000.0,
memory_total_mb: 48000.0,
temperature_celsius: 62.0,
};
apply_stream_event(
&mut state,
StreamEvent::TrainingUpdate {
sessions: vec![session],
gpu: gpu.clone(),
},
);
assert!(state.training.connected);
assert_eq!(state.training.sessions.len(), 1);
assert_eq!(state.training.loss_history.len(), 1);
// Loss should be epoch_loss as f64.
assert!((state.training.loss_history.back().unwrap() - 0.123_f64).abs() < 1e-3);
// GPU should be cloned to both training and system.
assert!((state.system.gpu.utilization_percent - 85.0).abs() < f32::EPSILON);
// Per-session history should be accumulated.
assert_eq!(state.training.session_histories.len(), 1);
let hist = state.training.session_histories.get("DQN:fold0").unwrap();
assert_eq!(hist.loss.len(), 1);
assert!((hist.loss[0] - 0.123_f64).abs() < 1e-3);
assert!(state.dirty);
}
#[test]
fn test_apply_order_update() {
let mut state = DashboardState::default();
apply_stream_event(
&mut state,
StreamEvent::OrderUpdate {
order_id: "ord-1".into(),
symbol: "ES".into(),
status: "Filled".into(),
filled_qty: 2.0,
last_fill_price: 5100.25,
timestamp_nanos: 52_321_000_000_000,
},
);
assert!(state.trading.connected);
assert_eq!(state.trading.fills.len(), 1);
let fill = state.trading.fills.front().unwrap();
assert_eq!(fill.time, "14:32:01");
assert_eq!(fill.side, "BUY");
assert_eq!(fill.symbol, "ES");
assert!((fill.quantity - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_apply_order_update_sell() {
let mut state = DashboardState::default();
apply_stream_event(
&mut state,
StreamEvent::OrderUpdate {
order_id: "ord-2".into(),
symbol: "NQ".into(),
status: "Filled".into(),
filled_qty: -1.0,
last_fill_price: 18000.0,
timestamp_nanos: 0,
},
);
let fill = state.trading.fills.front().unwrap();
assert_eq!(fill.side, "SELL");
assert!((fill.quantity - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_apply_positions_snapshot() {
let mut state = DashboardState::default();
let positions = vec![
PositionRow {
symbol: "ES".into(),
side: "LONG".into(),
quantity: 2.0,
entry_price: 5000.0,
unrealized_pnl: 150.0,
status: "Open".into(),
},
PositionRow {
symbol: "NQ".into(),
side: "SHORT".into(),
quantity: 1.0,
entry_price: 18000.0,
unrealized_pnl: -50.0,
status: "Open".into(),
},
];
apply_stream_event(
&mut state,
StreamEvent::PositionsSnapshot {
positions: positions.clone(),
},
);
assert!(state.trading.connected);
assert_eq!(state.trading.positions.len(), 2);
assert!((state.trading.unrealized_pnl - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_apply_risk_metrics() {
let mut state = DashboardState::default();
apply_stream_event(
&mut state,
StreamEvent::RiskMetrics {
var: 0.02,
max_drawdown: 0.05,
current_drawdown: 0.01,
sharpe: 2.5,
},
);
assert!(state.risk.connected);
assert!((state.risk.portfolio_var - 0.02).abs() < f64::EPSILON);
assert!((state.risk.max_drawdown - 0.05).abs() < f64::EPSILON);
assert!((state.risk.current_drawdown - 0.01).abs() < f64::EPSILON);
assert!((state.risk.sharpe_ratio - 2.5).abs() < f64::EPSILON);
}
#[test]
fn test_apply_risk_alert_insert() {
let mut state = DashboardState::default();
apply_stream_event(
&mut state,
StreamEvent::RiskAlert {
severity: "WARNING".into(),
symbol: "ES".into(),
message: "max_drawdown".into(),
threshold: 0.05,
current: 0.03,
},
);
assert!(state.risk.connected);
assert_eq!(state.risk.circuit_breakers.len(), 1);
assert_eq!(state.risk.circuit_breakers[0].name, "max_drawdown");
assert_eq!(state.risk.circuit_breakers[0].status, "OK");
}
#[test]
fn test_apply_risk_alert_tripped() {
let mut state = DashboardState::default();
apply_stream_event(
&mut state,
StreamEvent::RiskAlert {
severity: "CRITICAL".into(),
symbol: "ES".into(),
message: "max_drawdown".into(),
threshold: 0.05,
current: 0.06,
},
);
assert_eq!(state.risk.circuit_breakers[0].status, "TRIPPED");
}
#[test]
fn test_apply_system_status_upsert() {
let mut state = DashboardState::default();
// Insert first time.
apply_stream_event(
&mut state,
StreamEvent::SystemStatus {
service: "gateway".into(),
status: "HEALTHY".into(),
message: "ok".into(),
},
);
assert_eq!(state.system.services.len(), 1);
// Update same service.
apply_stream_event(
&mut state,
StreamEvent::SystemStatus {
service: "gateway".into(),
status: "DEGRADED".into(),
message: "high latency".into(),
},
);
// Should still be 1 entry, not 2.
assert_eq!(state.system.services.len(), 1);
assert_eq!(state.system.services[0].status, "DEGRADED");
assert_eq!(state.system.services[0].message, "high latency");
assert!(state.system.connected);
}
#[test]
fn test_apply_stream_disconnected() {
let mut state = DashboardState::default();
state.training.connected = true;
state.trading.connected = true;
state.risk.connected = true;
state.system.connected = true;
apply_stream_event(
&mut state,
StreamEvent::StreamDisconnected {
stream_name: "training".into(),
},
);
assert!(!state.training.connected);
apply_stream_event(
&mut state,
StreamEvent::StreamDisconnected {
stream_name: "risk".into(),
},
);
assert!(!state.risk.connected);
apply_stream_event(
&mut state,
StreamEvent::StreamDisconnected {
stream_name: "system".into(),
},
);
assert!(!state.system.connected);
apply_stream_event(
&mut state,
StreamEvent::StreamDisconnected {
stream_name: "orders".into(),
},
);
assert!(!state.trading.connected);
// Dirty flag should be set after each event.
assert!(state.dirty);
}
#[test]
fn test_training_update_filters_empty_model() {
let mut state = DashboardState::default();
let valid = TrainingSession {
model: "DQN".into(),
fold: "fold0".into(),
epoch_loss: 0.5,
..Default::default()
};
let empty = TrainingSession {
model: String::new(),
fold: String::new(),
..Default::default()
};
// Summary row: model present but no fold and epoch=0 (aggregate row from proto)
let summary = TrainingSession {
model: "DQN".into(),
fold: String::new(),
epoch: 0.0,
..Default::default()
};
apply_stream_event(
&mut state,
StreamEvent::TrainingUpdate {
sessions: vec![empty, summary, valid],
gpu: GpuInfo::default(),
},
);
// Only the session with a non-empty model AND a fold (or non-zero epoch) should remain.
assert_eq!(state.training.sessions.len(), 1);
assert_eq!(state.training.sessions[0].model, "DQN");
assert_eq!(state.training.session_histories.len(), 1);
assert!(state.training.session_histories.contains_key("DQN:fold0"));
}
}

View File

@@ -1,10 +0,0 @@
pub mod state;
mod streams;
mod render;
mod event_loop;
use anyhow::Result;
pub async fn run(api_gateway_url: &str, jwt_token: &str) -> Result<()> {
event_loop::run_dashboard(api_gateway_url, jwt_token).await
}

View File

@@ -1,947 +0,0 @@
//! Ratatui renderer for the `fxt watch` dashboard.
//!
//! One public entry point ([`render`]) dispatches to per-tab helpers.
//! No tests — visual rendering tests are brittle and not worth maintaining.
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Sparkline, Table, TableState, Tabs},
Frame,
};
use super::state::{
DashboardState, DetailSubTab, RiskTabState, SessionHistory, SystemTabState, Tab,
TradingTabState, TrainingTabState, TrainingViewMode,
};
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the full dashboard into the given frame.
pub(super) fn render(frame: &mut Frame, state: &DashboardState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // tab bar
Constraint::Min(0), // content
Constraint::Length(1), // status bar
])
.split(frame.area());
render_tab_bar(frame, chunks[0], state);
match state.current_tab() {
Tab::Training => match state.training.view_mode {
TrainingViewMode::List => render_training_list(frame, chunks[1], &state.training),
TrainingViewMode::Detail => {
render_training_detail(frame, chunks[1], &state.training);
}
},
Tab::Trading => render_trading_tab(frame, chunks[1], &state.trading),
Tab::Risk => render_risk_tab(frame, chunks[1], &state.risk),
Tab::System => render_system_tab(frame, chunks[1], &state.system),
}
render_status_bar(frame, chunks[2], state);
}
// ---------------------------------------------------------------------------
// Tab bar
// ---------------------------------------------------------------------------
fn render_tab_bar(frame: &mut Frame, area: Rect, state: &DashboardState) {
let titles: Vec<Line<'_>> = Tab::ALL
.iter()
.map(|t| Line::from(Span::raw(t.title())))
.collect();
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(" fxt watch "),
)
.select(state.active_tab)
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(tabs, area);
}
// ---------------------------------------------------------------------------
// Status bar
// ---------------------------------------------------------------------------
fn render_status_bar(frame: &mut Frame, area: Rect, state: &DashboardState) {
let hints: Vec<Span<'_>> =
if state.current_tab() == Tab::Training && state.training.view_mode == TrainingViewMode::Detail {
vec![
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" back "),
Span::styled("Tab/S-Tab", Style::default().fg(Color::Cyan)),
Span::raw(" sub-tab "),
Span::styled("h/l", Style::default().fg(Color::Cyan)),
Span::raw(" prev/next "),
Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(" quit"),
]
} else if state.current_tab() == Tab::Training {
vec![
Span::styled("1-4", Style::default().fg(Color::Cyan)),
Span::raw(" tabs "),
Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(" select "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" detail "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(" quit"),
]
} else {
vec![
Span::styled("1-4", Style::default().fg(Color::Cyan)),
Span::raw(" tabs "),
Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(" quit"),
]
};
let bar = Paragraph::new(Line::from(hints));
frame.render_widget(bar, area);
}
// ---------------------------------------------------------------------------
// Training tab
// ---------------------------------------------------------------------------
fn render_training_list(frame: &mut Frame, area: Rect, tab: &TrainingTabState) {
let conn_label = if tab.connected {
"connected"
} else {
"disconnected"
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // sessions table
Constraint::Length(6), // GPU + sparkline
])
.split(area);
// -- Sessions table with selection ------------------------------------
let header = Row::new(vec![
Cell::from("Model"),
Cell::from("Fold"),
Cell::from("Epoch"),
Cell::from("Loss"),
Cell::from("Val Loss"),
Cell::from("Acc"),
Cell::from("F1"),
Cell::from("LR"),
Cell::from("Batch/s"),
Cell::from("Grad"),
Cell::from("Sharpe"),
Cell::from("Win%"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row<'_>> = tab
.sessions
.iter()
.map(|s| {
Row::new(vec![
Cell::from(s.model.as_str()),
Cell::from(s.fold.as_str()),
Cell::from(format!("{:.1}", s.epoch)),
Cell::from(format!("{:.4}", s.epoch_loss)),
Cell::from(format!("{:.4}", s.validation_loss)),
Cell::from(format!("{:.4}", s.eval_accuracy)),
Cell::from(format!("{:.4}", s.eval_f1)),
Cell::from(format!("{:.2e}", s.learning_rate)),
Cell::from(format!("{:.1}", s.batches_per_second)),
Cell::from(format!("{:.3}", s.gradient_norm)),
Cell::from(if s.epoch_sharpe != 0.0 { format!("{:.2}", s.epoch_sharpe) } else { "-".to_owned() }),
Cell::from(if s.epoch_win_rate > 0.0 { format!("{:.0}%", s.epoch_win_rate * 100.0) } else { "-".to_owned() }),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
],
)
.header(header)
.row_highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Training [{conn_label}] ")),
);
let mut table_state = TableState::default().with_selected(tab.selected_index);
frame.render_stateful_widget(table, chunks[0], &mut table_state);
// -- Bottom row: GPU info (left) + loss sparkline (right) -------------
let bottom = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
let gpu = &tab.gpu;
let gpu_text = format!(
"GPU: {:.0}% VRAM: {:.0}/{:.0} MB Temp: {:.0}C",
gpu.utilization_percent, gpu.memory_used_mb, gpu.memory_total_mb, gpu.temperature_celsius,
);
let gpu_para = Paragraph::new(gpu_text).block(
Block::default()
.borders(Borders::ALL)
.title(" GPU "),
);
frame.render_widget(gpu_para, bottom[0]);
// Sparkline: clamp to 0..10, multiply by 100 for u64 resolution.
let spark_data: Vec<u64> = tab
.loss_history
.iter()
.map(|&v| {
let clamped = v.clamp(0.0, 10.0);
(clamped * 100.0) as u64
})
.collect();
let sparkline = Sparkline::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Loss "),
)
.data(&spark_data)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(sparkline, bottom[1]);
}
// ---------------------------------------------------------------------------
// Training detail view
// ---------------------------------------------------------------------------
/// Convert a `&[f64]` metric history to Sparkline-compatible `Vec<u64>`.
fn spark_u64(data: &[f64], max_clamp: f64) -> Vec<u64> {
data.iter()
.map(|&v| {
let clamped = v.clamp(0.0, max_clamp);
(clamped * 100.0) as u64
})
.collect()
}
fn render_sparkline_row(frame: &mut Frame, area: Rect, title: &str, data: &[f64], max: f64, color: Color) {
let vals = spark_u64(data, max);
let sparkline = Sparkline::default()
.block(Block::default().borders(Borders::ALL).title(format!(" {title} ")))
.data(&vals)
.style(Style::default().fg(color));
frame.render_widget(sparkline, area);
}
fn render_training_detail(frame: &mut Frame, area: Rect, tab: &TrainingTabState) {
let Some(session) = tab.selected_session() else {
let msg = Paragraph::new("No session selected")
.block(Block::default().borders(Borders::ALL).title(" Detail "));
frame.render_widget(msg, area);
return;
};
let vis_tabs = tab.visible_tabs();
let history_key = format!("{}:{}", session.model, session.fold);
let empty_hist = SessionHistory::default();
let history = tab.session_histories.get(&history_key).unwrap_or(&empty_hist);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // title bar
Constraint::Length(3), // sub-tab bar
Constraint::Min(0), // content
])
.split(area);
// -- Title bar --------------------------------------------------------
let status_label = if session.is_hyperopt {
format!(
"Hyperopt trial {}/{}",
session.hyperopt_trial_current, session.hyperopt_trial_total,
)
} else {
format!("Epoch {:.1}", session.epoch)
};
let title_text = format!(
" {} | {} | {} | GPU {:.0}%",
session.model, session.fold, status_label, tab.gpu.utilization_percent,
);
let title_bar = Paragraph::new(title_text).block(
Block::default()
.borders(Borders::ALL)
.title(" Session Detail "),
);
frame.render_widget(title_bar, chunks[0]);
// -- Sub-tab bar ------------------------------------------------------
let tab_titles: Vec<Line<'_>> = vis_tabs
.iter()
.map(|t| Line::from(Span::raw(t.title())))
.collect();
let active_idx = vis_tabs
.iter()
.position(|t| *t == tab.detail_sub_tab)
.unwrap_or(0);
let sub_tabs = Tabs::new(tab_titles)
.select(active_idx)
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(sub_tabs, chunks[1]);
// -- Content area: dispatch to sub-tab renderer -----------------------
match tab.detail_sub_tab {
DetailSubTab::Overview => render_detail_overview(frame, chunks[2], session, history),
DetailSubTab::Loss => render_detail_loss(frame, chunks[2], history),
DetailSubTab::RlDiagnostics => render_detail_rl(frame, chunks[2], session, history),
DetailSubTab::Metrics => render_detail_metrics(frame, chunks[2], session, history),
DetailSubTab::Hyperopt => render_detail_hyperopt(frame, chunks[2], session, history),
DetailSubTab::Health => render_detail_health(frame, chunks[2], session),
}
}
// -- Sub-tab renderers ----------------------------------------------------
fn render_detail_overview(
frame: &mut Frame,
area: Rect,
session: &super::state::TrainingSession,
history: &SessionHistory,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12), // key stats (incl. financial metrics)
Constraint::Min(0), // loss sparkline
])
.split(area);
// Key stats grid
let stats = Paragraph::new(vec![
Line::from(format!(
" Loss: {:.4} Val Loss: {:.4} LR: {:.2e}",
session.epoch_loss, session.validation_loss, session.learning_rate,
)),
Line::from(format!(
" Batch/s: {:.1} Grad Norm: {:.3} Epoch Time: {:.1}s",
session.batches_per_second, session.gradient_norm, session.epoch_duration_seconds,
)),
Line::from(format!(
" Batches: {:.0} Iter: {:.3}s Checkpoints: {} ({} failed)",
session.batches_processed,
session.iteration_seconds,
session.checkpoint_saves,
session.checkpoint_failures,
)),
Line::from(format!(
" NaN: {} Grad Explosions: {} Feature Errors: {}",
session.nan_detected, session.gradient_explosions, session.feature_errors,
)),
Line::from(""),
Line::from(format!(
" Sharpe: {:.2} Sortino: {:.2} Win Rate: {:.1}% Max DD: {:.1}%",
session.epoch_sharpe, session.epoch_sortino,
session.epoch_win_rate * 100.0, session.epoch_max_drawdown * 100.0,
)),
Line::from(format!(
" PF: {:.2} Return: {:+.2}% Avg: {:+.4} Trades: {}",
session.epoch_profit_factor, session.epoch_total_return * 100.0,
session.epoch_avg_return, session.epoch_total_trades,
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Overview "),
);
frame.render_widget(stats, chunks[0]);
render_sparkline_row(frame, chunks[1], "Loss", &history.loss, 10.0, Color::Cyan);
}
fn render_detail_loss(frame: &mut Frame, area: Rect, history: &SessionHistory) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
Constraint::Percentage(25),
])
.split(area);
render_sparkline_row(frame, chunks[0], "Loss", &history.loss, 10.0, Color::Cyan);
render_sparkline_row(frame, chunks[1], "Val Loss", &history.val_loss, 10.0, Color::Yellow);
render_sparkline_row(frame, chunks[2], "Learning Rate", &history.learning_rate, 1.0, Color::Green);
render_sparkline_row(frame, chunks[3], "Grad Norm", &history.gradient_norm, 100.0, Color::Red);
}
fn render_detail_rl(
frame: &mut Frame,
area: Rect,
session: &super::state::TrainingSession,
history: &SessionHistory,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
])
.split(area);
render_sparkline_row(frame, chunks[0], "Q-Value Mean", &history.q_value_mean, 100.0, Color::Cyan);
render_sparkline_row(frame, chunks[1], "Policy Entropy", &history.policy_entropy, 10.0, Color::Yellow);
render_sparkline_row(frame, chunks[2], "KL Divergence", &history.kl_divergence, 1.0, Color::Red);
render_sparkline_row(frame, chunks[3], "Advantage Mean", &history.advantage_mean, 10.0, Color::Green);
// Replay buffer gauge
let buf_size = session.replay_buffer_size;
let ratio = if buf_size > 0 {
(buf_size as f64 / 100_000.0).min(1.0)
} else {
0.0
};
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Replay Buffer: {buf_size} ")),
)
.gauge_style(Style::default().fg(Color::Cyan))
.ratio(ratio);
frame.render_widget(gauge, chunks[4]);
}
fn render_detail_metrics(
frame: &mut Frame,
area: Rect,
session: &super::state::TrainingSession,
history: &SessionHistory,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // current values (incl. action distribution)
Constraint::Min(0), // sparklines
])
.split(area);
let current = Paragraph::new(vec![
Line::from(format!(
" Accuracy: {:.4} F1: {:.4}",
session.eval_accuracy, session.eval_f1,
)),
Line::from(format!(
" Precision: {:.4} Recall: {:.4}",
session.eval_precision, session.eval_recall,
)),
Line::from(format!(
" Action: BUY {:.0}% SELL {:.0}% HOLD {:.0}%",
session.action_buy_pct * 100.0,
session.action_sell_pct * 100.0,
session.action_hold_pct * 100.0,
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Current Metrics "),
);
frame.render_widget(current, chunks[0]);
let spark_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
Constraint::Ratio(1, 8),
])
.split(chunks[1]);
render_sparkline_row(frame, spark_area[0], "Accuracy", &history.accuracy, 1.0, Color::Green);
render_sparkline_row(frame, spark_area[1], "Precision", &history.precision, 1.0, Color::Cyan);
render_sparkline_row(frame, spark_area[2], "Recall", &history.recall, 1.0, Color::Magenta);
render_sparkline_row(frame, spark_area[3], "F1", &history.f1, 1.0, Color::Yellow);
render_sparkline_row(frame, spark_area[4], "Sharpe", &history.sharpe, 20.0, Color::Cyan);
render_sparkline_row(frame, spark_area[5], "Win Rate", &history.win_rate, 1.0, Color::Green);
render_sparkline_row(frame, spark_area[6], "Max DD", &history.max_drawdown, 1.0, Color::Red);
render_sparkline_row(frame, spark_area[7], "Total Return", &history.total_return, 1.0, Color::Magenta);
}
fn render_detail_hyperopt(
frame: &mut Frame,
area: Rect,
session: &super::state::TrainingSession,
history: &SessionHistory,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), // stats
Constraint::Min(0), // best objective sparkline
])
.split(area);
let pct = if session.hyperopt_trial_total > 0 {
(session.hyperopt_trial_current as f32 / session.hyperopt_trial_total as f32) * 100.0
} else {
0.0
};
let stats = Paragraph::new(vec![
Line::from(format!(
" Trial: {}/{} ({:.0}%)",
session.hyperopt_trial_current, session.hyperopt_trial_total, pct,
)),
Line::from(format!(
" Best Objective: {:.6} Trial Best Loss: {:.6}",
session.hyperopt_best_objective, session.hyperopt_trial_best_loss,
)),
Line::from(format!(
" Trial Epoch: {} Failed: {} Elapsed: {:.0}s",
session.hyperopt_trial_epoch,
session.hyperopt_trials_failed,
session.hyperopt_elapsed_seconds,
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Hyperopt Progress "),
);
frame.render_widget(stats, chunks[0]);
render_sparkline_row(
frame,
chunks[1],
"Best Objective",
&history.hyperopt_best_objective,
10.0,
Color::Magenta,
);
}
fn render_detail_health(
frame: &mut Frame,
area: Rect,
session: &super::state::TrainingSession,
) {
let health = Paragraph::new(vec![
Line::from(format!(
" NaN Detected: {}",
session.nan_detected,
)),
Line::from(format!(
" Gradient Explosions: {}",
session.gradient_explosions,
)),
Line::from(format!(
" Feature Errors: {}",
session.feature_errors,
)),
Line::from(""),
Line::from(format!(
" Checkpoint Saves: {} Failures: {}",
session.checkpoint_saves, session.checkpoint_failures,
)),
Line::from(format!(
" Checkpoint Size: {:.1} MB",
session.checkpoint_size_bytes / (1024.0 * 1024.0),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(" Health "),
);
frame.render_widget(health, area);
}
// ---------------------------------------------------------------------------
// Trading tab
// ---------------------------------------------------------------------------
fn render_trading_tab(frame: &mut Frame, area: Rect, tab: &TradingTabState) {
let conn_label = if tab.connected {
"connected"
} else {
"disconnected"
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40), // positions
Constraint::Percentage(40), // fills
Constraint::Length(3), // PnL bar
])
.split(area);
// -- Positions table --------------------------------------------------
let pos_header = Row::new(vec![
Cell::from("Symbol"),
Cell::from("Side"),
Cell::from("Qty"),
Cell::from("Entry"),
Cell::from("PnL"),
Cell::from("Status"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let pos_rows: Vec<Row<'_>> = tab
.positions
.iter()
.map(|p| {
let pnl_color = if p.unrealized_pnl >= 0.0 {
Color::Green
} else {
Color::Red
};
Row::new(vec![
Cell::from(p.symbol.as_str()),
Cell::from(p.side.as_str()),
Cell::from(format!("{:.0}", p.quantity)),
Cell::from(format!("{:.2}", p.entry_price)),
Cell::from(Span::styled(
format!("{:+.2}", p.unrealized_pnl),
Style::default().fg(pnl_color),
)),
Cell::from(p.status.as_str()),
])
})
.collect();
let pos_table = Table::new(
pos_rows,
[
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Length(8),
],
)
.header(pos_header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Positions [{conn_label}] ")),
);
frame.render_widget(pos_table, chunks[0]);
// -- Fills table ------------------------------------------------------
let fill_header = Row::new(vec![
Cell::from("Time"),
Cell::from("Side"),
Cell::from("Symbol"),
Cell::from("Qty"),
Cell::from("Price"),
Cell::from("Status"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let fill_rows: Vec<Row<'_>> = tab
.fills
.iter()
.skip(tab.scroll_offset)
.take(10)
.map(|f| {
Row::new(vec![
Cell::from(f.time.as_str()),
Cell::from(f.side.as_str()),
Cell::from(f.symbol.as_str()),
Cell::from(format!("{:.0}", f.quantity)),
Cell::from(format!("{:.2}", f.price)),
Cell::from(f.status.as_str()),
])
})
.collect();
let fill_table = Table::new(
fill_rows,
[
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(8),
],
)
.header(fill_header)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Fills "),
);
frame.render_widget(fill_table, chunks[1]);
// -- PnL bar ----------------------------------------------------------
let pnl_color = if tab.day_pnl >= 0.0 {
Color::Green
} else {
Color::Red
};
let pnl_text = Line::from(vec![Span::styled(
format!(
"Day PnL: {:+.2} | Realized: {:+.2} Unrealized: {:+.2}",
tab.day_pnl, tab.realized_pnl, tab.unrealized_pnl,
),
Style::default().fg(pnl_color),
)]);
let pnl_bar = Paragraph::new(pnl_text).block(
Block::default()
.borders(Borders::ALL)
.title(" PnL "),
);
frame.render_widget(pnl_bar, chunks[2]);
}
// ---------------------------------------------------------------------------
// Risk tab
// ---------------------------------------------------------------------------
fn render_risk_tab(frame: &mut Frame, area: Rect, tab: &RiskTabState) {
let conn_label = if tab.connected {
"connected"
} else {
"disconnected"
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), // summary
Constraint::Min(0), // circuit breakers
])
.split(area);
// -- Metrics summary --------------------------------------------------
let summary = Paragraph::new(vec![
Line::from(format!(
" VaR: {:.2} Max Drawdown: {:.2}%",
tab.portfolio_var,
tab.max_drawdown * 100.0,
)),
Line::from(format!(
" Sharpe: {:.2} Current Drawdown: {:.2}%",
tab.sharpe_ratio,
tab.current_drawdown * 100.0,
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Risk [{conn_label}] ")),
);
frame.render_widget(summary, chunks[0]);
// -- Circuit breakers table -------------------------------------------
let cb_header = Row::new(vec![
Cell::from("Breaker"),
Cell::from("Status"),
Cell::from("Current"),
Cell::from("Limit"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let cb_rows: Vec<Row<'_>> = tab
.circuit_breakers
.iter()
.skip(tab.scroll_offset)
.map(|cb| {
let status_color = if cb.status == "OK" {
Color::Green
} else {
Color::Red
};
Row::new(vec![
Cell::from(cb.name.as_str()),
Cell::from(Span::styled(
cb.status.as_str(),
Style::default().fg(status_color),
)),
Cell::from(cb.current.as_str()),
Cell::from(cb.limit.as_str()),
])
})
.collect();
let cb_table = Table::new(
cb_rows,
[
Constraint::Length(18),
Constraint::Length(10),
Constraint::Length(15),
Constraint::Length(15),
],
)
.header(cb_header)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Circuit Breakers "),
);
frame.render_widget(cb_table, chunks[1]);
}
// ---------------------------------------------------------------------------
// System tab
// ---------------------------------------------------------------------------
fn render_system_tab(frame: &mut Frame, area: Rect, tab: &SystemTabState) {
let conn_label = if tab.connected {
"connected"
} else {
"disconnected"
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // services table
Constraint::Length(5), // resources bar
])
.split(area);
// -- Services table ---------------------------------------------------
let svc_header = Row::new(vec![
Cell::from("Service"),
Cell::from("Status"),
Cell::from("Latency"),
Cell::from("Message"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let svc_rows: Vec<Row<'_>> = tab
.services
.iter()
.skip(tab.scroll_offset)
.map(|s| {
let status_color = match s.status.as_str() {
"HEALTHY" | "UP" => Color::Green,
"DEGRADED" => Color::Yellow,
_ => Color::Red,
};
Row::new(vec![
Cell::from(s.name.as_str()),
Cell::from(Span::styled(
s.status.as_str(),
Style::default().fg(status_color),
)),
Cell::from(format!("{:.1}ms", s.latency_ms)),
Cell::from(s.message.as_str()),
])
})
.collect();
let svc_table = Table::new(
svc_rows,
[
Constraint::Length(22),
Constraint::Length(12),
Constraint::Length(10),
Constraint::Min(0),
],
)
.header(svc_header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Services [{conn_label}] ")),
);
frame.render_widget(svc_table, chunks[0]);
// -- Resources bar ----------------------------------------------------
let gpu = &tab.gpu;
let resources = Paragraph::new(format!(
" GPU: {:.0}% VRAM: {:.0}/{:.0} MB CPU: {:.1}% RAM: {:.1}/{:.1} GB",
gpu.utilization_percent,
gpu.memory_used_mb,
gpu.memory_total_mb,
tab.cpu_percent,
tab.ram_used_gb,
tab.ram_total_gb,
))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Resources "),
);
frame.render_widget(resources, chunks[1]);
}

View File

@@ -1,846 +0,0 @@
//! Dashboard state types for the `fxt watch` streaming TUI.
//!
//! All mutable state lives here so that the render and event-loop modules
//! can borrow it without circular dependencies.
use std::collections::{HashMap, VecDeque};
// ---------------------------------------------------------------------------
// Tab enum
// ---------------------------------------------------------------------------
/// Top-level dashboard tabs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Training,
Trading,
Risk,
System,
}
impl Tab {
/// All tabs in display order.
pub const ALL: [Tab; 4] = [Tab::Training, Tab::Trading, Tab::Risk, Tab::System];
/// Human-readable title for the tab bar.
pub fn title(self) -> &'static str {
match self {
Tab::Training => "Training",
Tab::Trading => "Trading",
Tab::Risk => "Risk",
Tab::System => "System",
}
}
/// Zero-based index matching [`Tab::ALL`].
pub fn index(self) -> usize {
match self {
Tab::Training => 0,
Tab::Trading => 1,
Tab::Risk => 2,
Tab::System => 3,
}
}
}
// ---------------------------------------------------------------------------
// Training types
// ---------------------------------------------------------------------------
/// Returns `true` if the model name indicates an RL model (DQN or PPO).
pub fn is_rl_model(model: &str) -> bool {
let lower = model.to_ascii_lowercase();
lower.contains("dqn") || lower.contains("ppo")
}
/// A single in-progress training session (all 35 proto fields + local gpu_percent).
#[derive(Debug, Clone, Default)]
pub struct TrainingSession {
pub model: String,
pub fold: String,
pub is_hyperopt: bool,
// Epoch / progress
pub epoch: f32,
pub epoch_loss: f32,
pub validation_loss: f32,
// Throughput
pub batches_per_second: f32,
pub batches_processed: f32,
pub iteration_seconds: f32,
// Eval metrics
pub eval_accuracy: f32,
pub eval_precision: f32,
pub eval_recall: f32,
pub eval_f1: f32,
// Checkpoint
pub checkpoint_size_bytes: f32,
pub checkpoint_saves: u32,
pub checkpoint_failures: u32,
// Health counters
pub nan_detected: u32,
pub gradient_explosions: u32,
pub feature_errors: u32,
// Hyperopt
pub hyperopt_trial_current: u32,
pub hyperopt_trial_total: u32,
pub hyperopt_best_objective: f32,
pub hyperopt_trials_failed: u32,
// RL diagnostics
pub q_value_mean: f32,
pub q_value_max: f32,
pub policy_entropy: f32,
pub kl_divergence: f32,
pub advantage_mean: f32,
pub replay_buffer_size: u32,
// Gradient & training health
pub gradient_norm: f32,
pub learning_rate: f32,
pub epoch_duration_seconds: f32,
// Hyperopt intra-trial
pub hyperopt_trial_epoch: u32,
pub hyperopt_trial_best_loss: f32,
pub hyperopt_elapsed_seconds: f32,
// Local (not from proto)
pub gpu_percent: f32,
}
/// Whether the Training tab shows the session list or a single-session detail.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrainingViewMode {
#[default]
List,
Detail,
}
/// Sub-tabs within the training detail view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailSubTab {
#[default]
Overview,
Loss,
RlDiagnostics,
Metrics,
Hyperopt,
Health,
}
impl DetailSubTab {
/// Human-readable label for the sub-tab bar.
pub fn title(self) -> &'static str {
match self {
Self::Overview => "Overview",
Self::Loss => "Loss",
Self::RlDiagnostics => "RL",
Self::Metrics => "Metrics",
Self::Hyperopt => "Hyperopt",
Self::Health => "Health",
}
}
}
/// Returns the sub-tabs visible for a given model/session configuration.
pub fn visible_sub_tabs(model: &str, is_hyperopt: bool) -> Vec<DetailSubTab> {
let mut tabs = vec![DetailSubTab::Overview, DetailSubTab::Loss];
if is_rl_model(model) {
tabs.push(DetailSubTab::RlDiagnostics);
} else {
tabs.push(DetailSubTab::Metrics);
}
if is_hyperopt {
tabs.push(DetailSubTab::Hyperopt);
}
tabs.push(DetailSubTab::Health);
tabs
}
/// Per-session unbounded metric history for sparklines.
#[derive(Debug, Clone, Default)]
pub struct SessionHistory {
pub loss: Vec<f64>,
pub val_loss: Vec<f64>,
pub learning_rate: Vec<f64>,
pub gradient_norm: Vec<f64>,
pub batches_per_second: Vec<f64>,
pub accuracy: Vec<f64>,
pub precision: Vec<f64>,
pub recall: Vec<f64>,
pub f1: Vec<f64>,
pub q_value_mean: Vec<f64>,
pub policy_entropy: Vec<f64>,
pub kl_divergence: Vec<f64>,
pub advantage_mean: Vec<f64>,
pub hyperopt_best_objective: Vec<f64>,
}
impl SessionHistory {
/// Append one sample from a training session snapshot.
pub fn push(&mut self, s: &TrainingSession) {
self.loss.push(f64::from(s.epoch_loss));
self.val_loss.push(f64::from(s.validation_loss));
self.learning_rate.push(f64::from(s.learning_rate));
self.gradient_norm.push(f64::from(s.gradient_norm));
self.batches_per_second.push(f64::from(s.batches_per_second));
self.accuracy.push(f64::from(s.eval_accuracy));
self.precision.push(f64::from(s.eval_precision));
self.recall.push(f64::from(s.eval_recall));
self.f1.push(f64::from(s.eval_f1));
self.q_value_mean.push(f64::from(s.q_value_mean));
self.policy_entropy.push(f64::from(s.policy_entropy));
self.kl_divergence.push(f64::from(s.kl_divergence));
self.advantage_mean.push(f64::from(s.advantage_mean));
self.hyperopt_best_objective.push(f64::from(s.hyperopt_best_objective));
}
}
/// GPU telemetry snapshot.
#[derive(Debug, Default, Clone)]
pub struct GpuInfo {
pub utilization_percent: f32,
pub memory_used_mb: f32,
pub memory_total_mb: f32,
pub temperature_celsius: f32,
}
/// State for the **Training** tab.
#[derive(Debug, Default)]
pub struct TrainingTabState {
pub sessions: Vec<TrainingSession>,
pub gpu: GpuInfo,
/// Ring buffer of recent loss values (max 200).
pub loss_history: VecDeque<f64>,
pub scroll_offset: usize,
pub connected: bool,
// -- Detail view state --
pub selected_index: Option<usize>,
pub view_mode: TrainingViewMode,
pub detail_sub_tab: DetailSubTab,
pub session_histories: HashMap<String, SessionHistory>,
}
const MAX_LOSS_HISTORY: usize = 200;
impl TrainingTabState {
/// Append a loss sample, evicting the oldest if the buffer is full.
pub fn push_loss(&mut self, value: f64) {
if self.loss_history.len() >= MAX_LOSS_HISTORY {
self.loss_history.pop_front();
}
self.loss_history.push_back(value);
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
/// Move selection cursor up (list mode).
pub fn select_up(&mut self) {
if let Some(idx) = self.selected_index {
self.selected_index = Some(idx.saturating_sub(1));
}
}
/// Move selection cursor down (list mode).
pub fn select_down(&mut self) {
match self.selected_index {
Some(idx) => {
let max = self.sessions.len().saturating_sub(1);
if idx < max {
self.selected_index = Some(idx.saturating_add(1));
}
}
None => {
if !self.sessions.is_empty() {
self.selected_index = Some(0);
}
}
}
}
/// Enter detail view for the currently selected session.
pub fn enter_detail(&mut self) {
if self.selected_index.is_some() && !self.sessions.is_empty() {
self.view_mode = TrainingViewMode::Detail;
self.detail_sub_tab = DetailSubTab::Overview;
self.scroll_offset = 0;
}
}
/// Return to list view.
pub fn exit_detail(&mut self) {
self.view_mode = TrainingViewMode::List;
self.scroll_offset = 0;
}
/// Advance to the next visible sub-tab.
pub fn next_sub_tab(&mut self) {
let tabs = match self.selected_index.and_then(|i| self.sessions.get(i)) {
Some(s) => visible_sub_tabs(&s.model, s.is_hyperopt),
None => return,
};
if let Some(pos) = tabs.iter().position(|t| *t == self.detail_sub_tab) {
if let Some(&next) = tabs.get(pos + 1) {
self.detail_sub_tab = next;
}
}
}
/// Go back to the previous visible sub-tab.
pub fn prev_sub_tab(&mut self) {
let tabs = match self.selected_index.and_then(|i| self.sessions.get(i)) {
Some(s) => visible_sub_tabs(&s.model, s.is_hyperopt),
None => return,
};
if let Some(pos) = tabs.iter().position(|t| *t == self.detail_sub_tab) {
if let Some(&prev) = pos.checked_sub(1).and_then(|p| tabs.get(p)) {
self.detail_sub_tab = prev;
}
}
}
/// The currently selected session, if any.
pub fn selected_session(&self) -> Option<&TrainingSession> {
self.selected_index.and_then(|i| self.sessions.get(i))
}
/// Sub-tabs visible for the currently selected session.
pub fn visible_tabs(&self) -> Vec<DetailSubTab> {
match self.selected_session() {
Some(s) => visible_sub_tabs(&s.model, s.is_hyperopt),
None => vec![DetailSubTab::Overview],
}
}
/// Clamp selected_index after sessions list changes.
pub fn clamp_selection(&mut self) {
match (self.sessions.is_empty(), self.selected_index) {
(true, _) => self.selected_index = None,
(false, Some(idx)) if idx >= self.sessions.len() => {
self.selected_index = Some(self.sessions.len().saturating_sub(1));
}
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Trading types
// ---------------------------------------------------------------------------
/// A single open position.
#[derive(Debug, Clone)]
pub struct PositionRow {
pub symbol: String,
pub side: String,
pub quantity: f64,
pub entry_price: f64,
pub unrealized_pnl: f64,
pub status: String,
}
/// A single fill / execution.
#[derive(Debug, Clone)]
pub struct FillRow {
pub time: String,
pub side: String,
pub symbol: String,
pub quantity: f64,
pub price: f64,
pub status: String,
}
/// State for the **Trading** tab.
#[derive(Debug, Default)]
pub struct TradingTabState {
pub positions: Vec<PositionRow>,
/// Recent fills ring buffer (max 50).
pub fills: VecDeque<FillRow>,
pub day_pnl: f64,
pub realized_pnl: f64,
pub unrealized_pnl: f64,
pub scroll_offset: usize,
pub connected: bool,
}
const MAX_FILLS: usize = 50;
impl TradingTabState {
/// Append a fill, evicting the oldest if the buffer is full.
pub fn push_fill(&mut self, fill: FillRow) {
if self.fills.len() >= MAX_FILLS {
self.fills.pop_front();
}
self.fills.push_back(fill);
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// Risk types
// ---------------------------------------------------------------------------
/// A single circuit breaker status row.
#[derive(Debug, Clone)]
pub struct CircuitBreakerRow {
pub name: String,
pub status: String,
pub current: String,
pub limit: String,
}
/// State for the **Risk** tab.
#[derive(Debug, Default)]
pub struct RiskTabState {
pub portfolio_var: f64,
pub max_drawdown: f64,
pub current_drawdown: f64,
pub sharpe_ratio: f64,
pub circuit_breakers: Vec<CircuitBreakerRow>,
pub scroll_offset: usize,
pub connected: bool,
}
impl RiskTabState {
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// System types
// ---------------------------------------------------------------------------
/// Health status of a single micro-service.
#[derive(Debug, Clone)]
pub struct ServiceRow {
pub name: String,
pub status: String,
pub message: String,
pub latency_ms: f64,
}
/// State for the **System** tab.
#[derive(Debug, Default)]
pub struct SystemTabState {
pub services: Vec<ServiceRow>,
pub gpu: GpuInfo,
pub cpu_percent: f64,
pub ram_used_gb: f64,
pub ram_total_gb: f64,
pub scroll_offset: usize,
pub connected: bool,
}
impl SystemTabState {
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// Top-level dashboard state
// ---------------------------------------------------------------------------
/// Root state object shared by the event loop and renderer.
#[derive(Debug, Default)]
pub struct DashboardState {
pub active_tab: usize,
pub training: TrainingTabState,
pub trading: TradingTabState,
pub risk: RiskTabState,
pub system: SystemTabState,
/// Set to `true` whenever any field changes so the renderer knows to
/// repaint. The render pass resets it to `false`.
pub dirty: bool,
}
impl DashboardState {
/// Returns the [`Tab`] variant for the currently active index.
pub fn current_tab(&self) -> Tab {
*Tab::ALL
.get(self.active_tab)
.unwrap_or(&Tab::Training)
}
/// Switch to a tab by index (clamped to valid range).
pub fn select_tab(&mut self, index: usize) {
if index < Tab::ALL.len() {
self.active_tab = index;
self.dirty = true;
}
}
/// Delegate scroll-up to the active tab.
pub fn scroll_up(&mut self) {
match self.current_tab() {
Tab::Training => self.training.scroll_up(),
Tab::Trading => self.trading.scroll_up(),
Tab::Risk => self.risk.scroll_up(),
Tab::System => self.system.scroll_up(),
}
self.dirty = true;
}
/// Delegate scroll-down to the active tab.
pub fn scroll_down(&mut self) {
match self.current_tab() {
Tab::Training => self.training.scroll_down(),
Tab::Trading => self.trading.scroll_down(),
Tab::Risk => self.risk.scroll_down(),
Tab::System => self.system.scroll_down(),
}
self.dirty = true;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_tab_selection() {
let mut state = DashboardState::default();
assert_eq!(state.current_tab(), Tab::Training);
state.select_tab(2);
assert_eq!(state.current_tab(), Tab::Risk);
assert_eq!(state.active_tab, 2);
assert!(state.dirty);
}
#[test]
fn test_tab_selection_out_of_bounds() {
let mut state = DashboardState::default();
state.select_tab(99);
// Should remain at the default (0 = Training).
assert_eq!(state.active_tab, 0);
assert_eq!(state.current_tab(), Tab::Training);
}
#[test]
fn test_loss_history_ring_buffer() {
let mut ts = TrainingTabState::default();
for i in 0..250 {
ts.push_loss(i as f64);
}
assert_eq!(ts.loss_history.len(), 200);
// Oldest surviving entry should be 50.0 (0..49 were evicted).
assert_eq!(*ts.loss_history.front().unwrap(), 50.0);
}
#[test]
fn test_fill_ring_buffer() {
let mut ts = TradingTabState::default();
for i in 0..60 {
ts.push_fill(FillRow {
time: format!("t{i}"),
side: "Buy".into(),
symbol: "ES".into(),
quantity: 1.0,
price: 5000.0,
status: "Filled".into(),
});
}
assert_eq!(ts.fills.len(), 50);
}
#[test]
fn test_scroll() {
let mut state = DashboardState::default();
// Scroll down twice.
state.scroll_down();
state.scroll_down();
assert_eq!(state.training.scroll_offset, 2);
// Scroll up once.
state.scroll_up();
assert_eq!(state.training.scroll_offset, 1);
// Scroll up past zero -- must not underflow.
state.scroll_up();
state.scroll_up();
assert_eq!(state.training.scroll_offset, 0);
}
// -- is_rl_model --------------------------------------------------------
#[test]
fn test_is_rl_model() {
assert!(is_rl_model("DQN"));
assert!(is_rl_model("dqn"));
assert!(is_rl_model("PPO"));
assert!(is_rl_model("ContinuousPPO"));
assert!(!is_rl_model("TFT"));
assert!(!is_rl_model("Mamba2"));
assert!(!is_rl_model("XLSTM"));
}
// -- visible_sub_tabs ---------------------------------------------------
#[test]
fn test_visible_sub_tabs_rl() {
let tabs = visible_sub_tabs("DQN", false);
assert_eq!(
tabs,
vec![
DetailSubTab::Overview,
DetailSubTab::Loss,
DetailSubTab::RlDiagnostics,
DetailSubTab::Health,
]
);
}
#[test]
fn test_visible_sub_tabs_supervised() {
let tabs = visible_sub_tabs("TFT", false);
assert_eq!(
tabs,
vec![
DetailSubTab::Overview,
DetailSubTab::Loss,
DetailSubTab::Metrics,
DetailSubTab::Health,
]
);
}
#[test]
fn test_visible_sub_tabs_hyperopt_rl() {
let tabs = visible_sub_tabs("PPO", true);
assert_eq!(
tabs,
vec![
DetailSubTab::Overview,
DetailSubTab::Loss,
DetailSubTab::RlDiagnostics,
DetailSubTab::Hyperopt,
DetailSubTab::Health,
]
);
}
#[test]
fn test_visible_sub_tabs_hyperopt_supervised() {
let tabs = visible_sub_tabs("TFT", true);
assert_eq!(
tabs,
vec![
DetailSubTab::Overview,
DetailSubTab::Loss,
DetailSubTab::Metrics,
DetailSubTab::Hyperopt,
DetailSubTab::Health,
]
);
}
// -- SessionHistory::push -----------------------------------------------
#[test]
fn test_session_history_push() {
let mut hist = SessionHistory::default();
let session = TrainingSession {
epoch_loss: 0.5,
validation_loss: 0.6,
learning_rate: 1e-3,
gradient_norm: 1.2,
batches_per_second: 42.0,
eval_accuracy: 0.85,
eval_f1: 0.82,
q_value_mean: 3.5,
policy_entropy: 1.1,
kl_divergence: 0.02,
advantage_mean: 0.1,
hyperopt_best_objective: 0.45,
..Default::default()
};
hist.push(&session);
hist.push(&session);
assert_eq!(hist.loss.len(), 2);
assert!((hist.loss[0] - 0.5).abs() < f64::EPSILON);
assert!((hist.accuracy[0] - 0.85).abs() < 1e-6);
assert!((hist.q_value_mean[0] - 3.5).abs() < 1e-6);
}
// -- select_up / select_down --------------------------------------------
fn make_tab_with_sessions(n: usize) -> TrainingTabState {
let mut tab = TrainingTabState::default();
for i in 0..n {
tab.sessions.push(TrainingSession {
model: format!("DQN_{i}"),
fold: format!("fold_{i}"),
..Default::default()
});
}
tab
}
#[test]
fn test_select_down_from_none() {
let mut tab = make_tab_with_sessions(3);
assert_eq!(tab.selected_index, None);
tab.select_down();
assert_eq!(tab.selected_index, Some(0));
}
#[test]
fn test_select_down_clamps_at_end() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(2);
tab.select_down();
assert_eq!(tab.selected_index, Some(2));
}
#[test]
fn test_select_up_clamps_at_zero() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(0);
tab.select_up();
assert_eq!(tab.selected_index, Some(0));
}
#[test]
fn test_select_down_empty_sessions() {
let mut tab = TrainingTabState::default();
tab.select_down();
assert_eq!(tab.selected_index, None);
}
#[test]
fn test_select_up_down_navigation() {
let mut tab = make_tab_with_sessions(5);
tab.selected_index = Some(0);
tab.select_down();
tab.select_down();
assert_eq!(tab.selected_index, Some(2));
tab.select_up();
assert_eq!(tab.selected_index, Some(1));
}
// -- mode transitions ---------------------------------------------------
#[test]
fn test_enter_detail_with_selection() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(1);
tab.enter_detail();
assert_eq!(tab.view_mode, TrainingViewMode::Detail);
assert_eq!(tab.detail_sub_tab, DetailSubTab::Overview);
}
#[test]
fn test_enter_detail_without_selection_noop() {
let mut tab = make_tab_with_sessions(3);
tab.enter_detail();
assert_eq!(tab.view_mode, TrainingViewMode::List);
}
#[test]
fn test_exit_detail() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(0);
tab.enter_detail();
tab.scroll_offset = 5;
tab.exit_detail();
assert_eq!(tab.view_mode, TrainingViewMode::List);
assert_eq!(tab.scroll_offset, 0);
}
// -- sub-tab navigation -------------------------------------------------
#[test]
fn test_next_prev_sub_tab() {
let mut tab = make_tab_with_sessions(1);
tab.selected_index = Some(0);
tab.enter_detail();
// DQN -> Overview, Loss, RL, Health
assert_eq!(tab.detail_sub_tab, DetailSubTab::Overview);
tab.next_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Loss);
tab.next_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::RlDiagnostics);
tab.next_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Health);
// At the end — should stay
tab.next_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Health);
// Go back
tab.prev_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::RlDiagnostics);
tab.prev_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Loss);
tab.prev_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Overview);
// At start — should stay
tab.prev_sub_tab();
assert_eq!(tab.detail_sub_tab, DetailSubTab::Overview);
}
// -- clamp_selection ----------------------------------------------------
#[test]
fn test_clamp_selection_empty() {
let mut tab = TrainingTabState::default();
tab.selected_index = Some(5);
tab.clamp_selection();
assert_eq!(tab.selected_index, None);
}
#[test]
fn test_clamp_selection_out_of_bounds() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(10);
tab.clamp_selection();
assert_eq!(tab.selected_index, Some(2));
}
#[test]
fn test_clamp_selection_valid() {
let mut tab = make_tab_with_sessions(3);
tab.selected_index = Some(1);
tab.clamp_selection();
assert_eq!(tab.selected_index, Some(1));
}
// -- DetailSubTab::title ------------------------------------------------
#[test]
fn test_detail_sub_tab_titles() {
assert_eq!(DetailSubTab::Overview.title(), "Overview");
assert_eq!(DetailSubTab::Loss.title(), "Loss");
assert_eq!(DetailSubTab::RlDiagnostics.title(), "RL");
assert_eq!(DetailSubTab::Metrics.title(), "Metrics");
assert_eq!(DetailSubTab::Hyperopt.title(), "Hyperopt");
assert_eq!(DetailSubTab::Health.title(), "Health");
}
}

View File

@@ -1,805 +0,0 @@
//! gRPC stream manager for `fxt watch`.
//!
//! Spawns one tokio task per server-streaming RPC plus one one-shot task for
//! initial state (positions + risk metrics). All tasks funnel [`StreamEvent`]s
//! into a single `mpsc` channel that the event loop consumes.
//!
//! Every streaming task runs an infinite retry loop with exponential backoff
//! (1 s initial, 30 s cap) so the dashboard stays alive across transient
//! network failures.
use anyhow::{Context, Result};
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tonic::metadata::{Ascii, MetadataValue};
use tonic::transport::Channel;
use tracing::{debug, warn};
use crate::proto::monitoring::{
self as mon, monitoring_service_client::MonitoringServiceClient,
};
use crate::proto::trading::{self as trd, trading_service_client::TradingServiceClient};
use super::state::{GpuInfo, PositionRow, TrainingSession};
// ---------------------------------------------------------------------------
// StreamEvent — the single enum consumed by the event loop
// ---------------------------------------------------------------------------
/// Typed events produced by the individual gRPC stream tasks.
#[derive(Debug, Clone)]
#[allow(dead_code)] // Fields like order_id, severity, symbol reserved for future UI use
pub(super) enum StreamEvent {
/// Training telemetry snapshot (sessions + GPU).
TrainingUpdate {
sessions: Vec<TrainingSession>,
gpu: GpuInfo,
},
/// A single order/fill update.
OrderUpdate {
order_id: String,
symbol: String,
status: String,
filled_qty: f64,
last_fill_price: f64,
timestamp_nanos: i64,
},
/// Full positions snapshot (replaces the previous set).
PositionsSnapshot { positions: Vec<PositionRow> },
/// Periodic risk-metric broadcast.
RiskMetrics {
var: f64,
max_drawdown: f64,
current_drawdown: f64,
sharpe: f64,
},
/// Risk circuit-breaker alert.
RiskAlert {
severity: String,
symbol: String,
message: String,
threshold: f64,
current: f64,
},
/// Service health heartbeat.
SystemStatus {
service: String,
status: String,
message: String,
},
/// Indicates that a named stream has disconnected.
StreamDisconnected { stream_name: String },
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Create a lazily-connected gRPC channel to the given URL.
fn connect_channel(url: &str) -> Result<Channel> {
crate::client::connect_channel_lazy(url)
}
/// Parse a JWT string into a tonic `MetadataValue` suitable for the
/// `authorization` header.
fn auth_metadata(jwt: &str) -> Result<MetadataValue<Ascii>> {
let bearer = format!("Bearer {jwt}");
bearer
.parse::<MetadataValue<Ascii>>()
.context("JWT token contains invalid header characters")
}
// ---------------------------------------------------------------------------
// Backoff helper
// ---------------------------------------------------------------------------
/// Capped exponential backoff: doubles each call, capped at 30 s.
fn next_backoff(current: std::time::Duration) -> std::time::Duration {
let doubled = current.saturating_mul(2);
let cap = std::time::Duration::from_secs(30);
if doubled > cap {
cap
} else {
doubled
}
}
const INITIAL_BACKOFF: std::time::Duration = std::time::Duration::from_secs(1);
// ---------------------------------------------------------------------------
// Proto -> state conversions
// ---------------------------------------------------------------------------
fn convert_training_session(s: &mon::TrainingSession) -> TrainingSession {
TrainingSession {
model: s.model.clone(),
fold: s.fold.clone(),
is_hyperopt: s.is_hyperopt,
// Epoch / progress
epoch: s.current_epoch,
epoch_loss: s.epoch_loss,
validation_loss: s.validation_loss,
// Throughput
batches_per_second: s.batches_per_second,
batches_processed: s.batches_processed,
iteration_seconds: s.iteration_seconds,
// Eval metrics
eval_accuracy: s.eval_accuracy,
eval_precision: s.eval_precision,
eval_recall: s.eval_recall,
eval_f1: s.eval_f1,
// Checkpoint
checkpoint_size_bytes: s.checkpoint_size_bytes,
checkpoint_saves: s.checkpoint_saves,
checkpoint_failures: s.checkpoint_failures,
// Health counters
nan_detected: s.nan_detected,
gradient_explosions: s.gradient_explosions,
feature_errors: s.feature_errors,
// Hyperopt
hyperopt_trial_current: s.hyperopt_trial_current,
hyperopt_trial_total: s.hyperopt_trial_total,
hyperopt_best_objective: s.hyperopt_best_objective,
hyperopt_trials_failed: s.hyperopt_trials_failed,
// RL diagnostics
q_value_mean: s.q_value_mean,
q_value_max: s.q_value_max,
policy_entropy: s.policy_entropy,
kl_divergence: s.kl_divergence,
advantage_mean: s.advantage_mean,
replay_buffer_size: s.replay_buffer_size,
// Gradient & training health
gradient_norm: s.gradient_norm,
learning_rate: s.learning_rate,
epoch_duration_seconds: s.epoch_duration_seconds,
// Hyperopt intra-trial
hyperopt_trial_epoch: s.hyperopt_trial_epoch,
hyperopt_trial_best_loss: s.hyperopt_trial_best_loss,
hyperopt_elapsed_seconds: s.hyperopt_elapsed_seconds,
// Local
gpu_percent: 0.0, // per-session GPU not in proto; filled from GpuSnapshot
}
}
fn convert_gpu(g: &mon::GpuSnapshot) -> GpuInfo {
GpuInfo {
utilization_percent: g.utilization_percent,
memory_used_mb: g.memory_used_mb,
memory_total_mb: g.memory_total_mb,
temperature_celsius: g.temperature_celsius,
}
}
fn convert_position(p: &trd::Position) -> PositionRow {
let side = if p.quantity >= 0.0 { "Long" } else { "Short" };
PositionRow {
symbol: p.symbol.clone(),
side: side.to_owned(),
quantity: p.quantity.abs(),
entry_price: p.average_cost,
unrealized_pnl: p.unrealized_pnl,
status: "Open".to_owned(),
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Spawn background tasks for every dashboard stream and return the unified
/// receiver.
///
/// Five tasks are spawned:
/// 1. Training metrics stream (monitoring service)
/// 2. Order updates stream (trading service)
/// 3. Risk alerts stream (trading service)
/// 4. System status stream (trading service)
/// 5. Initial state fetch -- one-shot unary RPCs for positions + risk metrics
///
/// Each streaming task will reconnect automatically on transient failures and
/// emit [`StreamEvent::StreamDisconnected`] when the connection drops.
pub(super) fn spawn_all_streams(
api_gateway_url: &str,
jwt_token: &str,
) -> mpsc::Receiver<StreamEvent> {
let (tx, rx) = mpsc::channel::<StreamEvent>(256);
// 1. Training metrics stream
{
let url = api_gateway_url.to_owned();
let jwt = jwt_token.to_owned();
let sender = tx.clone();
tokio::spawn(async move {
stream_training_loop(&url, &jwt, &sender).await;
});
}
// 2. Order updates stream
{
let url = api_gateway_url.to_owned();
let jwt = jwt_token.to_owned();
let sender = tx.clone();
tokio::spawn(async move {
stream_orders_loop(&url, &jwt, &sender).await;
});
}
// 3. Risk alerts stream
{
let url = api_gateway_url.to_owned();
let jwt = jwt_token.to_owned();
let sender = tx.clone();
tokio::spawn(async move {
stream_risk_alerts_loop(&url, &jwt, &sender).await;
});
}
// 4. System status stream
{
let url = api_gateway_url.to_owned();
let jwt = jwt_token.to_owned();
let sender = tx.clone();
tokio::spawn(async move {
stream_system_status_loop(&url, &jwt, &sender).await;
});
}
// 5. One-shot initial state
{
let url = api_gateway_url.to_owned();
let jwt = jwt_token.to_owned();
// Last clone — `tx` itself is dropped after the block, leaving only
// the spawned tasks holding senders.
tokio::spawn(async move {
if let Err(e) = fetch_initial_state(&url, &jwt, &tx).await {
warn!(error = %e, "failed to fetch initial state");
}
});
}
rx
}
// ---------------------------------------------------------------------------
// Training metrics stream
// ---------------------------------------------------------------------------
async fn stream_training_loop(url: &str, jwt: &str, tx: &mpsc::Sender<StreamEvent>) -> ! {
let mut backoff = INITIAL_BACKOFF;
loop {
match try_stream_training(url, jwt, tx).await {
Ok(()) => {
// Stream ended cleanly (server closed) -- reset backoff.
backoff = INITIAL_BACKOFF;
}
Err(e) => {
warn!(error = %e, "training metrics stream error");
}
}
drop(
tx.send(StreamEvent::StreamDisconnected {
stream_name: "training_metrics".to_owned(),
})
.await,
);
debug!(backoff_ms = backoff.as_millis(), "reconnecting training stream");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
async fn try_stream_training(
url: &str,
jwt: &str,
tx: &mpsc::Sender<StreamEvent>,
) -> Result<()> {
let channel = connect_channel(url)?;
let token = auth_metadata(jwt)?;
let mut client = MonitoringServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut()
.insert("authorization", token.clone());
Ok(req)
},
);
let request = mon::StreamTrainingMetricsRequest {
model_filter: String::new(),
interval_seconds: 3,
};
let response = client
.stream_training_metrics(request)
.await
.context("StreamTrainingMetrics RPC failed")?;
let mut stream = response.into_inner();
while let Some(frame) = stream.next().await {
let snapshot = frame.context("training metrics stream message error")?;
let sessions: Vec<TrainingSession> =
snapshot.sessions.iter().map(convert_training_session).collect();
let gpu = snapshot.gpu.as_ref().map(convert_gpu).unwrap_or_default();
tx.send(StreamEvent::TrainingUpdate { sessions, gpu })
.await
.context("event channel closed")?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Order updates stream
// ---------------------------------------------------------------------------
async fn stream_orders_loop(url: &str, jwt: &str, tx: &mpsc::Sender<StreamEvent>) -> ! {
let mut backoff = INITIAL_BACKOFF;
loop {
match try_stream_orders(url, jwt, tx).await {
Ok(()) => {
backoff = INITIAL_BACKOFF;
}
Err(e) => {
warn!(error = %e, "order updates stream error");
}
}
drop(
tx.send(StreamEvent::StreamDisconnected {
stream_name: "order_updates".to_owned(),
})
.await,
);
debug!(backoff_ms = backoff.as_millis(), "reconnecting orders stream");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
async fn try_stream_orders(
url: &str,
jwt: &str,
tx: &mpsc::Sender<StreamEvent>,
) -> Result<()> {
let channel = connect_channel(url)?;
let token = auth_metadata(jwt)?;
let mut client = TradingServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut()
.insert("authorization", token.clone());
Ok(req)
},
);
let request = trd::SubscribeOrderUpdatesRequest { account_id: None };
let response = client
.subscribe_order_updates(request)
.await
.context("SubscribeOrderUpdates RPC failed")?;
let mut stream = response.into_inner();
while let Some(frame) = stream.next().await {
let update = frame.context("order updates stream message error")?;
tx.send(StreamEvent::OrderUpdate {
order_id: update.order_id,
symbol: update.symbol,
status: format!("{}", update.status),
filled_qty: update.filled_quantity,
last_fill_price: update.last_fill_price,
timestamp_nanos: update.timestamp_unix_nanos,
})
.await
.context("event channel closed")?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Risk alerts stream
// ---------------------------------------------------------------------------
async fn stream_risk_alerts_loop(url: &str, jwt: &str, tx: &mpsc::Sender<StreamEvent>) -> ! {
let mut backoff = INITIAL_BACKOFF;
loop {
match try_stream_risk_alerts(url, jwt, tx).await {
Ok(()) => {
backoff = INITIAL_BACKOFF;
}
Err(e) => {
warn!(error = %e, "risk alerts stream error");
}
}
drop(
tx.send(StreamEvent::StreamDisconnected {
stream_name: "risk_alerts".to_owned(),
})
.await,
);
debug!(backoff_ms = backoff.as_millis(), "reconnecting risk alerts stream");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
async fn try_stream_risk_alerts(
url: &str,
jwt: &str,
tx: &mpsc::Sender<StreamEvent>,
) -> Result<()> {
let channel = connect_channel(url)?;
let token = auth_metadata(jwt)?;
let mut client = TradingServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut()
.insert("authorization", token.clone());
Ok(req)
},
);
let request = trd::SubscribeRiskAlertsRequest {
min_severity: Vec::new(),
symbols: Vec::new(),
};
let response = client
.subscribe_risk_alerts(request)
.await
.context("SubscribeRiskAlerts RPC failed")?;
let mut stream = response.into_inner();
while let Some(frame) = stream.next().await {
let alert = frame.context("risk alerts stream message error")?;
tx.send(StreamEvent::RiskAlert {
severity: format!("{}", alert.severity),
symbol: alert.symbol,
message: alert.message,
threshold: alert.threshold_value,
current: alert.current_value,
})
.await
.context("event channel closed")?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// System status stream
// ---------------------------------------------------------------------------
async fn stream_system_status_loop(url: &str, jwt: &str, tx: &mpsc::Sender<StreamEvent>) -> ! {
let mut backoff = INITIAL_BACKOFF;
loop {
match try_stream_system_status(url, jwt, tx).await {
Ok(()) => {
backoff = INITIAL_BACKOFF;
}
Err(e) => {
warn!(error = %e, "system status stream error");
}
}
drop(
tx.send(StreamEvent::StreamDisconnected {
stream_name: "system_status".to_owned(),
})
.await,
);
debug!(backoff_ms = backoff.as_millis(), "reconnecting system status stream");
tokio::time::sleep(backoff).await;
backoff = next_backoff(backoff);
}
}
async fn try_stream_system_status(
url: &str,
jwt: &str,
tx: &mpsc::Sender<StreamEvent>,
) -> Result<()> {
let channel = connect_channel(url)?;
let token = auth_metadata(jwt)?;
let mut client = TradingServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut()
.insert("authorization", token.clone());
Ok(req)
},
);
let request = trd::SubscribeSystemStatusRequest {
service_names: Vec::new(),
};
let response = client
.subscribe_system_status(request)
.await
.context("SubscribeSystemStatus RPC failed")?;
let mut stream = response.into_inner();
while let Some(frame) = stream.next().await {
let heartbeat = frame.context("system status stream message error")?;
let status_label = match heartbeat.status {
1 => "HEALTHY",
2 => "DEGRADED",
3 => "UNHEALTHY",
4 => "CRITICAL",
_ => "UNKNOWN",
};
tx.send(StreamEvent::SystemStatus {
service: heartbeat.service_name,
status: status_label.to_owned(),
message: heartbeat.message,
})
.await
.context("event channel closed")?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Initial state (one-shot unary RPCs)
// ---------------------------------------------------------------------------
/// Fetches positions and risk metrics via unary RPCs to seed the dashboard
/// with initial state before any streaming updates arrive.
async fn fetch_initial_state(
url: &str,
jwt: &str,
tx: &mpsc::Sender<StreamEvent>,
) -> Result<()> {
let channel = connect_channel(url)?;
let token = auth_metadata(jwt)?;
let mut client = TradingServiceClient::with_interceptor(
channel,
move |mut req: tonic::Request<()>| {
req.metadata_mut()
.insert("authorization", token.clone());
Ok(req)
},
);
// Fetch positions.
let positions_resp = client
.get_positions(trd::GetPositionsRequest { symbol: None })
.await
.context("GetPositions RPC failed")?;
let positions: Vec<PositionRow> = positions_resp
.into_inner()
.positions
.iter()
.map(convert_position)
.collect();
tx.send(StreamEvent::PositionsSnapshot { positions })
.await
.context("event channel closed")?;
// Fetch risk metrics.
let risk_resp = client
.get_risk_metrics(trd::GetRiskMetricsRequest {
portfolio_id: None,
start_time_unix_nanos: None,
end_time_unix_nanos: None,
})
.await
.context("GetRiskMetrics RPC failed")?;
let rm = risk_resp.into_inner();
tx.send(StreamEvent::RiskMetrics {
var: rm.value_at_risk,
max_drawdown: rm.max_drawdown,
current_drawdown: rm.current_drawdown,
sharpe: rm.sharpe_ratio,
})
.await
.context("event channel closed")?;
debug!("initial state fetched successfully");
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_backoff_doubles() {
let b = next_backoff(std::time::Duration::from_secs(1));
assert_eq!(b, std::time::Duration::from_secs(2));
}
#[test]
fn test_backoff_caps_at_30s() {
let b = next_backoff(std::time::Duration::from_secs(20));
assert_eq!(b, std::time::Duration::from_secs(30));
let b2 = next_backoff(std::time::Duration::from_secs(30));
assert_eq!(b2, std::time::Duration::from_secs(30));
}
#[test]
fn test_auth_metadata_valid() {
let meta = auth_metadata("eyJhbGciOiJIUzI1NiJ9.test.sig");
assert!(meta.is_ok());
}
#[tokio::test]
async fn test_connect_channel_valid_url() {
let ch = connect_channel("http://localhost:50051");
assert!(ch.is_ok());
}
#[test]
fn test_connect_channel_invalid_url() {
// Invalid URLs fail at the `from_shared` step, before any runtime is
// needed, so a sync test is fine.
let ch = connect_channel("not a url at all");
assert!(ch.is_err());
}
#[test]
fn test_convert_gpu() {
let gpu = convert_gpu(&mon::GpuSnapshot {
utilization_percent: 85.0,
memory_used_mb: 4096.0,
memory_total_mb: 8192.0,
temperature_celsius: 72.0,
power_watts: 200.0,
});
assert!((gpu.utilization_percent - 85.0).abs() < f32::EPSILON);
assert!((gpu.memory_used_mb - 4096.0).abs() < f32::EPSILON);
assert!((gpu.memory_total_mb - 8192.0).abs() < f32::EPSILON);
assert!((gpu.temperature_celsius - 72.0).abs() < f32::EPSILON);
}
#[test]
fn test_convert_position_long() {
let pos = convert_position(&trd::Position {
symbol: "ES".to_owned(),
quantity: 2.0,
market_price: 5100.0,
market_value: 10200.0,
average_cost: 5000.0,
unrealized_pnl: 200.0,
realized_pnl: 0.0,
});
assert_eq!(pos.side, "Long");
assert!((pos.quantity - 2.0).abs() < f64::EPSILON);
assert!((pos.entry_price - 5000.0).abs() < f64::EPSILON);
assert_eq!(pos.status, "Open");
}
#[test]
fn test_convert_position_short() {
let pos = convert_position(&trd::Position {
symbol: "NQ".to_owned(),
quantity: -1.0,
market_price: 18000.0,
market_value: -18000.0,
average_cost: 18100.0,
unrealized_pnl: 100.0,
realized_pnl: 0.0,
});
assert_eq!(pos.side, "Short");
assert!((pos.quantity - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_convert_position_zero_quantity() {
let pos = convert_position(&trd::Position {
symbol: "ZN".to_owned(),
quantity: 0.0,
market_price: 110.0,
market_value: 0.0,
average_cost: 109.0,
unrealized_pnl: 0.0,
realized_pnl: 50.0,
});
// Zero quantity is technically "Long" (>= 0).
assert_eq!(pos.side, "Long");
assert!((pos.quantity - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_convert_training_session() {
let sess = convert_training_session(&mon::TrainingSession {
model: "DQN".to_owned(),
fold: "fold_0".to_owned(),
is_hyperopt: false,
current_epoch: 5.0,
epoch_loss: 0.123,
validation_loss: 0.456,
learning_rate: 0.001,
batches_per_second: 42.0,
gradient_norm: 1.5,
epoch_duration_seconds: 12.0,
..Default::default()
});
assert_eq!(sess.model, "DQN");
assert_eq!(sess.fold, "fold_0");
assert!(!sess.is_hyperopt);
assert!((sess.epoch - 5.0).abs() < f32::EPSILON);
assert!((sess.epoch_loss - 0.123).abs() < f32::EPSILON);
assert!((sess.gradient_norm - 1.5).abs() < f32::EPSILON);
}
#[test]
fn test_stream_event_variants_are_clone() {
let event = StreamEvent::SystemStatus {
service: "gateway".into(),
status: "UP".into(),
message: "ok".into(),
};
let _cloned = event.clone();
}
#[test]
fn test_stream_event_all_variants() {
// Verify all variants can be constructed and cloned.
let events = vec![
StreamEvent::TrainingUpdate {
sessions: vec![],
gpu: GpuInfo::default(),
},
StreamEvent::OrderUpdate {
order_id: "o1".into(),
symbol: "ES".into(),
status: "3".into(),
filled_qty: 1.0,
last_fill_price: 5000.0,
timestamp_nanos: 123_456_789,
},
StreamEvent::PositionsSnapshot { positions: vec![] },
StreamEvent::RiskMetrics {
var: 0.05,
max_drawdown: 0.10,
current_drawdown: 0.02,
sharpe: 1.5,
},
StreamEvent::RiskAlert {
severity: "3".into(),
symbol: "NQ".into(),
message: "VaR breach".into(),
threshold: 0.05,
current: 0.07,
},
StreamEvent::SystemStatus {
service: "trading".into(),
status: "1".into(),
message: "healthy".into(),
},
StreamEvent::StreamDisconnected {
stream_name: "training_metrics".into(),
},
];
for e in &events {
let _ = e.clone();
}
assert_eq!(events.len(), 7);
}
}

43
bin/fxt/src/grpc.rs Normal file
View File

@@ -0,0 +1,43 @@
//! Unified gRPC client for the Foxhunt API Gateway.
//!
//! All service RPCs route through a single [`FoxhuntClient`] which holds
//! one lazily-connected [`Channel`] to the API Gateway. Individual typed
//! clients (trading, ML, risk, ...) are constructed on demand from the
//! shared channel.
use anyhow::Result;
use tonic::transport::Channel;
/// Unified gRPC client -- all service accessors share a single channel.
pub struct FoxhuntClient {
channel: Channel,
}
impl FoxhuntClient {
/// Create a client that will lazily connect to the API Gateway.
///
/// The TCP handshake is deferred until the first RPC call (`connect_lazy`).
///
/// # Errors
///
/// Returns an error if the URL cannot be parsed as a valid URI.
pub async fn connect(api_url: &str) -> Result<Self> {
let mut endpoint =
Channel::from_shared(api_url.to_owned()).map_err(|e| anyhow::anyhow!("{e}"))?;
// Auto-configure TLS for https endpoints.
if api_url.starts_with("https://") {
endpoint = endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
.map_err(|e| anyhow::anyhow!("TLS config error: {e}"))?;
}
let channel = endpoint.connect_lazy();
Ok(Self { channel })
}
/// Get the underlying channel for creating typed service clients.
pub fn channel(&self) -> Channel {
self.channel.clone()
}
}

View File

@@ -1,97 +1,36 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#![allow(missing_docs)] // Internal implementation details
#![allow(missing_debug_implementations)] // Not all types need Debug
#![allow(unused_crate_dependencies)] // Dev dependencies not used in lib.rs
// FXT CLI domain lints
#![allow(clippy::module_name_repetitions)] // Module-prefixed types provide clarity
#![allow(clippy::integer_division)] // Integer division is intentional in CLI display
#![allow(clippy::cognitive_complexity)] // CLI command handling is complex
#![allow(clippy::manual_let_else)] // if-let pattern preferred in CLI error paths
#![allow(clippy::unnecessary_wraps)] // Result wrapping needed for CLI commands
#![allow(clippy::similar_names)] // CLI variables often have similar names
#![allow(clippy::missing_const_for_fn)] // Const fn not critical for CLI code
#![allow(clippy::too_many_lines)] // CLI command functions can be long
#![allow(clippy::doc_markdown)] // Technical terms in doc comments
#![allow(clippy::must_use_candidate)] // Not all functions need must_use
#![allow(clippy::missing_errors_doc)] // Internal CLI APIs don't need full error docs
#![allow(clippy::default_numeric_fallback)] // Numeric literals are contextually typed
#![allow(clippy::unused_self)] // Self parameter needed for trait consistency
#![allow(clippy::unused_async)] // Async needed for trait implementations
#![allow(clippy::too_many_arguments)] // CLI command functions need many parameters
#![allow(clippy::indexing_slicing)] // Encryption code uses validated slice indices
#![allow(clippy::wildcard_in_or_patterns)] // Wildcard patterns in command matching
#![allow(clippy::redundant_pattern_matching)] // Explicit pattern matching preferred
#![allow(missing_docs)]
#![allow(missing_debug_implementations)]
#![allow(unused_crate_dependencies)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::cognitive_complexity)]
#![allow(clippy::unnecessary_wraps)]
#![allow(clippy::similar_names)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::default_numeric_fallback)]
#![allow(clippy::unused_self)]
#![allow(clippy::unused_async)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::wildcard_in_or_patterns)]
#![allow(clippy::redundant_pattern_matching)]
//! TLI (Terminal Line Interface) - Client for Foxhunt HFT Trading System
//! FXT -- Foxhunt Operations Platform CLI
//!
//! This module provides a comprehensive gRPC client infrastructure for connecting
//! to and monitoring core trading services including:
//!
//! ## Core Services
//! - **Trading Service**: Integrated service with all operations (trading, risk, monitoring, config, system status)
//! - **Backtesting Service**: Strategy testing, performance analysis, results management
//!
//! ## Key Features
//! - **Connection Management**: Pooling, health checks, automatic reconnection
//! - **Real-time Streaming**: Market data, order updates, system events
//! - **Error Handling**: Circuit breakers, exponential backoff, comprehensive error types
//! - **Security**: TLS support, authentication, credential management
//! - **Monitoring**: Metrics collection, performance tracking, alerting
//! - **High Availability**: Load balancing, failover, redundancy
//!
//! ## Architecture
//! ```text
//! TLI Client Suite
//! ├── Connection Manager (pooling, health checks)
//! ├── Event Stream Manager (real-time data)
//! ├── Trading Client (ALL operations: trading, risk, monitoring, config, system status)
//! └── Backtesting Client (strategy testing, performance analysis)
//! ```
//!
//! ## Example Usage
//! ```rust,no_run
//! use fxt::prelude::*;
//!
//! #[tokio::main]
//! async fn main() -> TliResult<()> {
//! // Create client suite with both services
//! let client_suite = TliClientBuilder::new()
//! .with_service_endpoint("trading_service".to_string(), "http://localhost:50051".to_string())
//! .with_service_endpoint("backtesting_service".to_string(), "http://localhost:50052".to_string())
//! .with_trading_config(TradingClientConfig::default())
//! .with_backtesting_config(BacktestingClientConfig::default())
//! .build()
//! .await?;
//!
//! // Use trading client
//! if let Some(trading_client) = &client_suite.trading_client {
//! // Submit order with integrated risk management
//! let order_request = SubmitOrderRequest {
//! symbol: "AAPL".to_string(),
//! side: OrderSide::Buy as i32,
//! order_type: OrderType::Market as i32,
//! quantity: 100.0,
//! client_order_id: "order_123".to_string(),
//! ..Default::default()
//! };
//!
//! let response = trading_client.submit_order(order_request).await?;
//! println!("Order submitted: {:?}", response);
//! }
//!
//! // Shutdown
//! client_suite.shutdown().await;
//! Ok(())
//! }
//! ```
//! Thin gRPC client that talks exclusively to the API Gateway.
//! All business logic lives in the backend services.
// Suppress false-positive unused extern crate warnings for dependencies used in modules
// Suppress false-positive unused extern crate warnings for dependencies
// that are used in sub-modules or generated code.
use chrono as _;
use clap as _;
use colored as _;
use common as _;
use dirs as _; // Used in config module for home directory
use dirs as _;
use futures_util as _;
use prost as _;
use rust_decimal as _;
@@ -99,94 +38,24 @@ use serde as _;
use serde_json as _;
use tabled as _;
use thiserror as _;
use toml as _; // Used in config module for TOML parsing
use toml as _;
use tonic as _;
use tracing_subscriber as _;
use uuid as _;
// Core modules
pub mod auth;
pub mod client;
pub mod commands;
pub mod config; // Configuration file support (~/.foxhunt/config.toml)
pub mod config;
pub mod error;
pub mod types;
pub mod grpc;
pub mod output;
// Prelude module for convenient imports
pub mod prelude;
// Placeholder modules - database module removed (should only exist in services)
/// Utility functions and helpers for TLI operations
///
/// This module contains shared utility functions used across the TLI client,
/// including formatting helpers, validation utilities, and common operations.
pub mod utils {}
/// Constants and configuration values for TLI
///
/// This module contains compile-time constants, default values, and configuration
/// parameters used throughout the TLI client application.
pub mod constants {}
// Terminal UI modules now enabled
#[cfg(test)]
pub mod tests;
/// TLI version information
/// FXT version information.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// TLI build information
pub const BUILD_INFO: BuildInfo = BuildInfo {
version: VERSION,
git_hash: match option_env!("GIT_HASH") {
Some(hash) => hash,
None => "unknown",
},
build_date: match option_env!("BUILD_DATE") {
Some(date) => date,
None => "unknown",
},
features: &[
// TLI features are defined by dependencies, not cargo features
"tonic-tls",
"grpc-client",
],
};
/// Build information structure
/// Generated protobuf code for gRPC client interfaces.
///
/// Contains compile-time information about the TLI binary including version,
/// source control metadata, and enabled feature flags for debugging and support.
#[derive(Debug, Clone)]
pub struct BuildInfo {
/// Semantic version string (e.g., "1.0.0")
pub version: &'static str,
/// Git commit hash from build time
pub git_hash: &'static str,
/// ISO 8601 build timestamp
pub build_date: &'static str,
/// List of enabled Cargo feature flags
pub features: &'static [&'static str],
}
impl std::fmt::Display for BuildInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"TLI v{} ({}), built on {}, features: [{}]",
self.version,
self.git_hash,
self.build_date,
self.features.join(", ")
)
}
}
/// Generated protobuf code for gRPC client interfaces
///
/// This module contains all the auto-generated protobuf types and service clients
/// used for communicating with the Foxhunt HFT system services via gRPC.
/// All protos are compiled from the workspace-root `proto/` directory.
pub mod proto {
/// Trading service protobuf definitions (package `trading`)

View File

@@ -1,615 +1,202 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
#![allow(clippy::cognitive_complexity)] // CLI command dispatching is inherently complex
#![allow(clippy::redundant_pattern_matching)] // Explicit pattern matching preferred
#![allow(clippy::cognitive_complexity)]
#![allow(clippy::redundant_pattern_matching)]
//! TLI (Terminal Line Interface) - Client Application for Foxhunt HFT Trading System
//! FXT -- Foxhunt Operations Platform CLI
//!
//! Pure client CLI application that connects to trading services:
//! - CLI commands for trading, backtesting, ML training, and tuning
//! - gRPC client connections to Trading and Backtesting services
//! - Authentication and secure token management
//! Thin gRPC client that talks to the API Gateway for all operations:
//! trading, training, tuning, risk, services, cluster, data, and more.
use anyhow::{Context, Result};
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use fxt::auth::token_manager::FileTokenStorage;
use fxt::{
commands::{
agent::{execute_agent_command, AgentArgs},
auth::{execute_auth_command, AuthCommand},
backtest_ml::{execute_backtest_ml_command, BacktestMlArgs},
broker::{execute_broker_command, BrokerArgs},
model::{execute_model_command, ModelCommand},
trade::{execute_trade_command, TradeArgs},
train::{execute_train_command, TrainCommand},
tune::{execute_tune_command, TuneCommand},
},
config::TliConfig,
use fxt::commands::{
agent, auth, backtest, broker, cluster, config_cmd, data, mcp, model, risk, service, trade,
train, tune, watch,
};
use tracing::Level;
use tracing_subscriber::FmtSubscriber;
use fxt::grpc::FoxhuntClient;
use fxt::output::OutputFormat;
// Suppress false-positive unused extern crate warnings for dependencies used in modules
use aes_gcm as _;
use argon2 as _;
use async_trait as _;
use base64 as _;
use chrono as _;
use clap as _;
use colored as _;
use comfy_table as _;
use common as _;
use console as _;
use dirs as _;
use futures_util as _;
use getrandom as _;
use hex as _;
#[cfg(feature = "broker-check")]
use ibapi as _;
use indicatif as _;
use keyring as _;
use owo_colors as _;
use prost as _;
use rand as _;
use rpassword as _;
use rust_decimal as _;
use serde as _;
use serde_json as _;
use sha2 as _;
use tabled as _;
use thiserror as _;
use toml as _;
use tonic as _;
use tonic_prost as _;
use uuid as _;
use zeroize as _;
// Configuration precedence (highest to lowest):
// 1. CLI arguments (--api-gateway-url)
// 2. Environment variables (API_GATEWAY_URL)
// 3. Config file (~/.foxhunt/config.toml)
// 4. Hardcoded defaults
/// TLI Command Line Interface
/// Foxhunt Operations Platform
#[derive(Parser)]
#[clap(
name = "fxt",
version,
about = "Foxhunt Trading System Terminal Interface",
long_about = "Foxhunt Trading System Terminal Interface\n\n\
Environment Variables:\n\
API_GATEWAY_URL API Gateway URL (default: https://api.fxhnt.ai)\n\
TLI_LOG_LEVEL Log level (default: info)\n\
TLI_TOKEN_STORAGE Token storage backend (default: keyring)\n\n\
Precedence: CLI args > Environment variables > Config file > Defaults"
)]
#[command(name = "fxt", about = "Foxhunt Operations Platform", version)]
struct Cli {
/// Subcommand to execute
#[clap(subcommand)]
/// API Gateway endpoint URL
#[arg(
long = "api-url",
env = "FXT_API_URL",
default_value = "https://api.fxhnt.ai"
)]
api_url: String,
/// Output as JSON (for CI/LLM integration)
#[arg(long, short)]
json: bool,
#[command(subcommand)]
command: Commands,
/// API Gateway URL
#[clap(
long,
env = "API_GATEWAY_URL",
default_value = "https://api.fxhnt.ai",
help = "API Gateway URL (env: API_GATEWAY_URL)"
)]
api_gateway_url: String,
/// Log level (trace, debug, info, warn, error)
#[clap(
long,
env = "TLI_LOG_LEVEL",
default_value = "info",
help = "Log level (env: TLI_LOG_LEVEL)"
)]
log_level: String,
/// Token storage backend (keyring, file)
#[clap(
long,
env = "TLI_TOKEN_STORAGE",
default_value = "keyring",
help = "Token storage backend (env: TLI_TOKEN_STORAGE)"
)]
token_storage: String,
}
/// TLI subcommands
#[derive(Subcommand)]
enum Commands {
/// Hyperparameter tuning for ML models (DQN, PPO, MAMBA-2, TFT)
#[clap(
long_about = "Start, monitor, and manage hyperparameter tuning jobs.\n\n\
Supported models: DQN, PPO, MAMBA_2, TFT, TLOB, LIQUID\n\
Uses Optuna for Bayesian optimization.\n\n\
Examples:\n\
fxt tune start --model DQN --trials 100\n\
fxt tune status --job-id <uuid>\n\
fxt tune best --job-id <uuid>\n\
fxt tune stop --job-id <uuid>"
)]
Tune {
#[clap(subcommand)]
tune_cmd: TuneCommand,
},
/// Training job management (list, status, stop)
#[clap(
long_about = "Manage ML model training jobs.\n\n\
Supported operations:\n\
- List training jobs with filtering\n\
- Get detailed job status\n\
- Watch real-time training progress\n\
- Stop jobs gracefully or forcefully\n\n\
Examples:\n\
fxt train list\n\
fxt train list --status RUNNING --model TFT\n\
fxt train status train_dqn_es_20251022_1430\n\
fxt train watch train_tft_nq_20251022_1500\n\
fxt train stop train_ppo_es_20251022_1600"
)]
Train {
#[clap(subcommand)]
train_cmd: TrainCommand,
},
/// Model promotion management (list pending, approve, reject)
#[clap(
long_about = "Manage ML model promotions to production.\n\n\
Supports operator-in-the-loop workflow for reviewing\n\
trained models before promoting to live trading.\n\n\
Examples:\n\
fxt model list\n\
fxt model approve <model-id>\n\
fxt model reject <model-id> --reason \"metrics below threshold\""
)]
Model {
#[clap(subcommand)]
model_cmd: ModelCommand,
},
/// Authentication and session management
#[clap(long_about = "Login, logout, and manage authentication tokens.\n\n\
JWT tokens are stored securely in OS keyring.\n\
Tokens auto-refresh when expiring (within 60 seconds).\n\n\
Examples:\n\
fxt auth login --username trader1\n\
fxt auth status\n\
fxt auth refresh\n\
fxt auth logout")]
Auth {
#[clap(subcommand)]
auth_cmd: AuthCommand,
},
/// Trading agent operations (universe selection, asset selection, portfolio allocation)
#[clap(
long_about = "Trading agent operations for automated portfolio management.\n\n\
Subcommands:\n\
allocate-portfolio - Allocate capital across selected assets\n\n\
Examples:\n\
fxt agent allocate-portfolio --selection-id abc-123 --total-capital 100000\n\
fxt agent allocate-portfolio --selection-id abc-123 --total-capital 100000 --strategy risk-parity"
)]
Agent {
#[command(flatten)]
agent_args: AgentArgs,
},
/// ML trading operations
#[clap(name = "backtest")]
Backtest {
#[command(flatten)]
backtest_args: BacktestMlArgs,
},
/// ML trading operations (legacy, use backtest ml instead)
#[clap(name = "trade")]
Trade {
#[command(flatten)]
trade_args: TradeArgs,
},
/// Broker connectivity check
#[clap(
long_about = "Validate broker connectivity.\n\n\
Checks:\n\
1. Direct IB Gateway: TCP + ibapi handshake\n\
2. Broker Gateway gRPC: HealthCheck + SessionStatus\n\n\
Examples:\n\
fxt broker check\n\
fxt broker check --host 10.0.0.5 --port 4004\n\
fxt broker check --skip-ibkr"
)]
Broker {
#[command(flatten)]
broker_args: BrokerArgs,
},
/// Live streaming dashboard
Watch,
}
/// JWT token claims structure for validation
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
/// Subject (`user_id`)
sub: String,
/// Expiration time (Unix timestamp in seconds)
exp: u64,
/// Issued at (Unix timestamp in seconds)
iat: u64,
/// JWT ID
jti: String,
/// User roles
roles: Vec<String>,
/// User permissions
permissions: Vec<String>,
}
/// Load and validate JWT token from OS keyring with automatic refresh
///
/// This function retrieves the access token from secure storage, validates expiration,
/// and automatically attempts refresh if the token is expired or expiring within 60 seconds.
///
/// # Arguments
/// * `api_gateway_url` - API Gateway URL for token refresh requests
///
/// # Returns
/// - `Ok(String)` - Valid access token (possibly refreshed)
/// - `Err(anyhow::Error)` - Token not found, refresh failed, or invalid format
async fn load_jwt_token(api_gateway_url: &str) -> Result<String> {
use fxt::auth::login::LoginClient;
use fxt::auth::token_manager::{AuthTokenManager, TokenStorage};
let storage = FileTokenStorage::new().context("Failed to initialize file token storage")?;
// Read access token directly from keyring
match storage.get_access_token().await? {
Some(token) => {
// Validate token expiry
if let Err(_) = validate_token_expiry(&token).await {
// Token expired - attempt refresh
tracing::info!("Token expired, attempting auto-refresh");
println!("{}", "Token expiring, refreshing...".yellow());
// Check if we have a refresh token
if storage.get_refresh_token().await?.is_none() {
anyhow::bail!(
"No refresh token available. Please login: {}",
"fxt auth login".bright_cyan()
);
}
// Store old token for comparison
let old_token = token.clone();
// Refresh tokens
let auth_manager = AuthTokenManager::new(storage.clone());
let channel = fxt::client::connect_channel_lazy(api_gateway_url)
.context("Invalid API Gateway URL")?;
let login_client = LoginClient::new(channel);
login_client
.refresh_tokens(&auth_manager)
.await
.context("Failed to refresh tokens")?;
// Verify new token was stored in keyring
match storage.get_access_token().await? {
Some(new_token) => {
// Verify token was actually updated
if new_token != old_token {
tracing::info!("\u{2713} New access token confirmed in keyring");
} else {
tracing::error!(
"\u{26a0} Token refresh did not update access token in keyring"
);
}
// Verify refresh token is still in keyring
match storage.get_refresh_token().await? {
Some(stored_refresh) => {
tracing::info!("\u{2713} Refresh token confirmed in keyring");
// Verify refresh token wasn't accidentally cleared
if stored_refresh.is_empty() {
anyhow::bail!(
"Token refresh succeeded but refresh token is empty in keyring. Please login again: {}",
"fxt auth login".bright_cyan()
)
}
},
None => {
anyhow::bail!(
"Token refresh succeeded but refresh token not found in keyring. Please login again: {}",
"fxt auth login".bright_cyan()
)
},
}
println!("{}", "\u{2713} Token refreshed successfully".green());
Ok(new_token)
},
None => {
anyhow::bail!(
"Token refresh succeeded but new token not found in keyring. Please login again: {}",
"fxt auth login".bright_cyan()
)
},
}
} else {
// Token is still valid
Ok(token)
}
},
None => {
anyhow::bail!(
"Not authenticated. Please run: {} first",
"fxt auth login".bright_cyan()
)
},
}
}
/// Validate JWT token expiration only (for refresh decision)
///
/// Checks if the token expires within 60 seconds without full validation.
///
/// # Arguments
/// * `token` - JWT token string to check
///
/// # Returns
/// - `Ok(())` - Token not expired and has >60 seconds remaining
/// - `Err(anyhow::Error)` - Token expired or expiring soon
async fn validate_token_expiry(token: &str) -> Result<()> {
// Parse token WITHOUT verification (only check expiry)
let mut validation = Validation::new(Algorithm::HS256);
validation.insecure_disable_signature_validation();
validation.validate_exp = false;
let token_data = decode::<Claims>(token, &DecodingKey::from_secret(b"dummy"), &validation)
.context("Invalid token format")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if token_data.claims.exp <= now + 60 {
anyhow::bail!("Token expired or expiring soon")
}
Ok(())
/// Authentication
Auth(auth::AuthCommand),
/// Order management
Trade(trade::TradeCommand),
/// ML training lifecycle
Train(train::TrainCommand),
/// Hyperparameter optimization
Tune(tune::TuneCommand),
/// Model management
Model(model::ModelCommand),
/// Trading agent control
Agent(agent::AgentCommand),
/// Backtesting
Backtest(backtest::BacktestCommand),
/// Broker connectivity
Broker(broker::BrokerCommand),
/// Data pipeline management
Data(data::DataCommand),
/// Service operations
Service(service::ServiceCommand),
/// Cluster operations
Cluster(cluster::ClusterCommand),
/// Risk management
Risk(risk::RiskCommand),
/// System configuration
Config(config_cmd::ConfigCommand),
/// TUI cockpit dashboard
Watch(watch::WatchCommand),
/// MCP server mode
Mcp(mcp::McpCommand),
}
#[tokio::main]
async fn main() -> Result<()> {
// Load config file (~/.foxhunt/config.toml)
let config = TliConfig::load().unwrap_or_default();
// Parse CLI args
let mut cli = Cli::parse();
// Merge: CLI args override config file (precedence: CLI > Config > Default)
if cli.api_gateway_url == "https://api.fxhnt.ai" {
// Using default, check if config has override
cli.api_gateway_url = config.api_gateway_url.clone();
}
if cli.log_level == "info" {
// Using default, check if config has override
cli.log_level = config.log_level.clone();
}
// Parse log level
let log_level = match cli.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
let cli = Cli::parse();
let format = if cli.json {
OutputFormat::Json
} else {
OutputFormat::Human
};
// Initialize tracing with configured log level.
// For the Watch (TUI) command, redirect logs to a file so they don't
// corrupt the ratatui alternate-screen display.
let is_tui = matches!(cli.command, Commands::Watch);
if is_tui {
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".foxhunt");
std::fs::create_dir_all(&log_dir).ok();
if let Ok(file) = std::fs::File::create(log_dir.join("watch.log")) {
let subscriber = FmtSubscriber::builder()
.with_max_level(log_level)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.finish();
tracing::subscriber::set_global_default(subscriber).ok();
}
} else {
let subscriber = FmtSubscriber::builder().with_max_level(log_level).finish();
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Warning: Failed to set global tracing subscriber: {}", e);
}
// Commands that do not need a gRPC connection.
match &cli.command {
Commands::Watch(cmd) => return cmd.execute().await,
Commands::Mcp(cmd) => return cmd.execute().await,
Commands::Auth(_)
| Commands::Trade(_)
| Commands::Train(_)
| Commands::Tune(_)
| Commands::Model(_)
| Commands::Agent(_)
| Commands::Backtest(_)
| Commands::Broker(_)
| Commands::Data(_)
| Commands::Service(_)
| Commands::Cluster(_)
| Commands::Risk(_)
| Commands::Config(_) => {}
}
// Route to subcommands
match cli.command {
Commands::Tune { tune_cmd } => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
execute_tune_command(tune_cmd, &cli.api_gateway_url, &jwt_token).await
}
Commands::Train { train_cmd } => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
execute_train_command(train_cmd, &cli.api_gateway_url, &jwt_token).await
}
Commands::Model { model_cmd } => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
execute_model_command(model_cmd, &cli.api_gateway_url, &jwt_token).await
}
Commands::Auth { auth_cmd } => execute_auth_command(auth_cmd).await,
Commands::Agent { agent_args } => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
execute_agent_command(agent_args, &cli.api_gateway_url, &jwt_token).await
}
Commands::Backtest { backtest_args } => {
execute_backtest_ml_command(backtest_args).await
}
Commands::Trade { trade_args } => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
execute_trade_command(trade_args, &cli.api_gateway_url, &jwt_token).await
}
Commands::Broker { broker_args } => {
let passed = execute_broker_command(broker_args, &config).await?;
if !passed {
std::process::exit(1);
}
Ok(())
}
Commands::Watch => {
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
fxt::commands::watch::run(&cli.api_gateway_url, &jwt_token).await
}
// Connect to API Gateway (lazy -- no TCP until first RPC).
let client = FoxhuntClient::connect(&cli.api_url).await?;
match &cli.command {
Commands::Auth(cmd) => cmd.execute(&client, format).await,
Commands::Trade(cmd) => cmd.execute(&client, format).await,
Commands::Train(cmd) => cmd.execute(&client, format).await,
Commands::Tune(cmd) => cmd.execute(&client, format).await,
Commands::Model(cmd) => cmd.execute(&client, format).await,
Commands::Agent(cmd) => cmd.execute(&client, format).await,
Commands::Backtest(cmd) => cmd.execute(&client, format).await,
Commands::Broker(cmd) => cmd.execute(&client, format).await,
Commands::Data(cmd) => cmd.execute(&client, format).await,
Commands::Service(cmd) => cmd.execute(&client, format).await,
Commands::Cluster(cmd) => cmd.execute(&client, format).await,
Commands::Risk(cmd) => cmd.execute(&client, format).await,
Commands::Config(cmd) => cmd.execute(&client, format).await,
// Already handled above -- unreachable.
Commands::Watch(_) | Commands::Mcp(_) => Ok(()),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_main_function_exists() {
// This test ensures the main function compiles
// Terminal application testing would require mock terminal backend
assert!(true);
fn test_cli_parse_auth_status() {
let cli = Cli::parse_from(["fxt", "auth", "status"]);
assert!(!cli.json);
assert_eq!(cli.api_url, "https://api.fxhnt.ai");
}
#[test]
fn test_cli_parsing_tune_command() {
// Test that Cli struct parses tune command correctly
let cli = Cli::parse_from(&[
"fxt",
"--api-gateway-url",
"http://test.com",
"tune",
"status",
"--job-id",
"550e8400-e29b-41d4-a716-446655440000",
fn test_cli_parse_json_flag() {
let cli = Cli::parse_from(["fxt", "--json", "auth", "status"]);
assert!(cli.json);
}
#[test]
fn test_cli_parse_api_url_override() {
let cli = Cli::parse_from(["fxt", "--api-url", "http://localhost:9090", "auth", "login"]);
assert_eq!(cli.api_url, "http://localhost:9090");
}
#[test]
fn test_cli_parse_trade_submit() {
let cli = Cli::parse_from([
"fxt", "trade", "submit", "--symbol", "ES.FUT", "--side", "buy", "--qty", "1",
]);
assert_eq!(cli.api_gateway_url, "http://test.com");
match cli.command {
Commands::Tune { .. } => {},
_ => panic!("Expected Tune command"),
}
assert!(matches!(cli.command, Commands::Trade(_)));
}
#[test]
fn test_cli_parsing_auth_command() {
let cli = Cli::parse_from(&["fxt", "auth", "status"]);
match cli.command {
Commands::Auth { .. } => {},
_ => panic!("Expected Auth command"),
}
fn test_cli_parse_service_list() {
let cli = Cli::parse_from(["fxt", "service", "list"]);
assert!(matches!(cli.command, Commands::Service(_)));
}
#[test]
fn test_cli_default_values() {
// Test default values when no flags provided
let cli = Cli::parse_from(&["fxt", "auth", "status"]);
assert_eq!(cli.api_gateway_url, "https://api.fxhnt.ai");
assert_eq!(cli.log_level, "info");
assert_eq!(cli.token_storage, "keyring");
fn test_cli_parse_watch() {
let cli = Cli::parse_from(["fxt", "watch"]);
assert!(matches!(cli.command, Commands::Watch(_)));
}
#[test]
fn test_cli_custom_values() {
// Test that custom values override defaults
let cli = Cli::parse_from(&[
"fxt",
"--api-gateway-url",
"http://custom.com:8080",
"--log-level",
"debug",
"--token-storage",
"file",
"auth",
"status",
]);
fn test_cli_parse_all_subcommands() {
// Verify every top-level subcommand parses without error.
let commands = [
vec!["fxt", "auth", "status"],
vec!["fxt", "trade", "positions"],
vec!["fxt", "train", "list"],
vec!["fxt", "tune", "status", "some-id"],
vec!["fxt", "model", "list"],
vec!["fxt", "agent", "status"],
vec![
"fxt", "backtest", "run", "--strategy", "s", "--symbol", "ES", "--from",
"2025-01-01", "--to", "2025-12-31",
],
vec!["fxt", "broker", "status"],
vec!["fxt", "data", "status"],
vec!["fxt", "service", "health"],
vec!["fxt", "cluster", "status"],
vec!["fxt", "risk", "status"],
vec!["fxt", "config", "env"],
vec!["fxt", "watch"],
vec!["fxt", "mcp"],
];
assert_eq!(cli.api_gateway_url, "http://custom.com:8080");
assert_eq!(cli.log_level, "debug");
assert_eq!(cli.token_storage, "file");
}
#[test]
fn test_tune_command_with_all_args() {
let cli = Cli::parse_from(&[
"fxt",
"tune",
"start",
"--model",
"DQN",
"--trials",
"100",
"--config",
"test_config.yaml",
"--gpu",
]);
match cli.command {
Commands::Tune { .. } => {},
_ => panic!("Expected Tune command"),
}
}
#[test]
fn test_auth_login_command_parsing() {
let cli = Cli::parse_from(&["fxt", "auth", "login", "--username", "testuser"]);
match cli.command {
Commands::Auth { .. } => {},
_ => panic!("Expected Auth command"),
}
}
#[test]
fn test_cli_parsing_broker_check() {
let cli = Cli::parse_from(&["fxt", "broker", "check"]);
match cli.command {
Commands::Broker { .. } => {},
_ => panic!("Expected Broker command"),
}
}
#[test]
fn test_cli_parsing_broker_check_with_flags() {
let cli = Cli::parse_from(&[
"fxt",
"broker",
"check",
"--host",
"10.0.0.5",
"--port",
"4001",
"--skip-gateway",
]);
match cli.command {
Commands::Broker { .. } => {},
_ => panic!("Expected Broker command"),
for args in &commands {
let result = Cli::try_parse_from(args.iter());
assert!(
result.is_ok(),
"Failed to parse: {:?} -- {}",
args,
result.err().map_or_else(String::new, |e| e.to_string())
);
}
}
}

45
bin/fxt/src/output.rs Normal file
View File

@@ -0,0 +1,45 @@
//! Output format abstraction for JSON and human-readable rendering.
//!
//! Every command result type implements [`HumanReadable`] so the CLI can
//! switch between `--json` (machine-readable) and the default tabular output
//! with a single [`render`] call.
use serde::Serialize;
use std::io::Write;
/// Output format selected by the global `--json` flag.
#[derive(Debug, Clone, Copy)]
pub enum OutputFormat {
/// Default: pretty-printed tables/text for humans.
Human,
/// Structured JSON (for CI pipelines, LLM tool-use, jq).
Json,
}
/// Render any serializable result in the chosen format.
///
/// # Errors
///
/// Returns an error if JSON serialization or stdout writing fails.
pub fn render<T: Serialize + HumanReadable>(result: &T, format: OutputFormat) -> anyhow::Result<()> {
match format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(result)?;
let mut stdout = std::io::stdout().lock();
writeln!(stdout, "{json}")?;
}
OutputFormat::Human => {
result.print_human();
}
}
Ok(())
}
/// Trait for human-readable terminal rendering.
///
/// Implementors print directly to stdout using `println!`, `colored`, or
/// `tabled` — whatever looks best for the data type.
pub trait HumanReadable {
/// Print a human-friendly representation to stdout.
fn print_human(&self);
}

View File

@@ -1,22 +0,0 @@
//! Prelude module for convenient imports
//!
//! This module re-exports commonly used types and functions for easy access
//! in TLI client code and examples.
// Error types
pub use crate::error::{TliError, TliResult};
// Client types
pub use crate::client::{ClientFactory, ServiceEndpoints, TliClientBuilder, TliClientSuite};
// Client configurations and implementations
pub use crate::client::backtesting_client::{BacktestingClient, BacktestingClientConfig};
pub use crate::client::connection_manager::{ConnectionConfig, ConnectionManager};
pub use crate::client::ml_training_client::{MLTrainingClient, MLTrainingClientConfig};
pub use crate::client::trading_client::{TradingClient, TradingClientConfig};
// Proto types - common trading types
pub use crate::proto::trading::{
CancelOrderRequest, GetPositionsRequest, OrderSide, OrderStatus, OrderType, Position,
SubmitOrderRequest, Trade,
};

View File

@@ -1,515 +0,0 @@
//! Unit tests for TLI components
//!
//! This module contains comprehensive unit tests for all TLI functionality
//! including client connections, type conversions, error handling, and
//! configuration management.
#![allow(dead_code)]
// use crate::client::{TliClient, ServiceEndpoints}; // Disabled due to compilation issues
use crate::error::TliError;
use crate::types::*;
use proptest::prelude::*;
use std::time::SystemTime;
mod client_tests {
#[test]
fn test_tli_basic_functionality() {
// Basic functionality test since ServiceEndpoints is disabled
assert!(true);
}
#[test]
#[ignore = "ServiceEndpoints disabled due to compilation issues"]
fn test_service_endpoints_environment_override() {
// std::env::set_var("FOXHUNT_TRADING_ENGINE_URL", "http://custom:8080");
// std::env::set_var("FOXHUNT_RISK_MANAGEMENT_URL", "http://custom:8081");
// let endpoints = ServiceEndpoints::default();
// assert_eq!(endpoints.trading_engine, "http://custom:8080");
// assert_eq!(endpoints.risk_management, "http://custom:8081");
// Clean up
// std::env::remove_var("FOXHUNT_TRADING_ENGINE_URL");
// std::env::remove_var("FOXHUNT_RISK_MANAGEMENT_URL");
}
#[test]
#[ignore = "TliClient disabled due to compilation issues"]
fn test_client_creation() {
// let client = TliClient::new();
// assert!(client.trading.is_none());
// assert!(client.monitoring.is_none());
// assert!(client.config.is_none());
// assert!(client.health.is_none());
}
#[test]
#[ignore = "ServiceEndpoints and TliClient disabled due to compilation issues"]
fn test_client_with_custom_endpoints() {
// let endpoints = ServiceEndpoints {
// trading_engine: "http://test:1001".to_string(),
// risk_management: "http://test:1002".to_string(),
// ml_signals: "http://test:1003".to_string(),
// market_data: "http://test:1004".to_string(),
// health_check: "http://test:1005".to_string(),
// };
// let client = TliClient::with_endpoints(endpoints.clone());
// assert_eq!(client.endpoints.trading_engine, endpoints.trading_engine);
// assert_eq!(client.endpoints.risk_management, endpoints.risk_management);
}
#[test]
#[ignore = "TliClient disabled due to compilation issues"]
fn test_service_not_connected_errors() {
// let mut client = TliClient::new();
// assert!(matches!(client.trading(), Err(TliError::NotConnected(_))));
// assert!(matches!(client.monitoring(), Err(TliError::NotConnected(_))));
// assert!(matches!(client.config(), Err(TliError::NotConnected(_))));
}
}
mod types_tests {
use super::*;
#[test]
fn test_timestamp_conversions() {
let now = SystemTime::now();
let nanos = system_time_to_unix_nanos(now);
let converted = unix_nanos_to_system_time(nanos);
// Allow for small timing differences (< 1ms)
let diff = now
.duration_since(converted)
.unwrap_or_else(|_| converted.duration_since(now).unwrap());
assert!(diff.as_millis() < 1);
}
#[test]
fn test_current_unix_nanos() {
let timestamp1 = current_unix_nanos();
std::thread::sleep(std::time::Duration::from_millis(1));
let timestamp2 = current_unix_nanos();
assert!(timestamp2 > timestamp1);
assert!(timestamp2 - timestamp1 > 0);
}
#[test]
fn test_order_side_conversions() {
// Use TliOrderSide instead of core OrderSide
assert_eq!(order_side_to_string(TliOrderSide::Buy), "BUY");
assert_eq!(order_side_to_string(TliOrderSide::Sell), "SELL");
assert_eq!(string_to_order_side("BUY").unwrap(), TliOrderSide::Buy);
assert_eq!(string_to_order_side("buy").unwrap(), TliOrderSide::Buy);
assert_eq!(string_to_order_side("SELL").unwrap(), TliOrderSide::Sell);
assert_eq!(string_to_order_side("sell").unwrap(), TliOrderSide::Sell);
string_to_order_side("INVALID").unwrap_err();
string_to_order_side("").unwrap_err();
}
#[test]
fn test_order_type_conversions() {
use crate::proto::trading::OrderType;
assert_eq!(order_type_to_string(OrderType::Market), "MARKET");
assert_eq!(order_type_to_string(OrderType::Limit), "LIMIT");
assert_eq!(order_type_to_string(OrderType::Stop), "STOP");
assert_eq!(order_type_to_string(OrderType::StopLimit), "STOP_LIMIT");
assert_eq!(string_to_order_type("MARKET").unwrap(), OrderType::Market);
assert_eq!(string_to_order_type("LIMIT").unwrap(), OrderType::Limit);
assert_eq!(string_to_order_type("STOP").unwrap(), OrderType::Stop);
assert_eq!(
string_to_order_type("STOP_LIMIT").unwrap(),
OrderType::StopLimit
);
string_to_order_type("INVALID").unwrap_err();
}
#[test]
fn test_order_status_conversions() {
use crate::proto::trading::OrderStatus;
assert_eq!(order_status_to_string(OrderStatus::New), "NEW");
assert_eq!(
order_status_to_string(OrderStatus::PartiallyFilled),
"PARTIALLY_FILLED"
);
assert_eq!(order_status_to_string(OrderStatus::Filled), "FILLED");
assert_eq!(order_status_to_string(OrderStatus::Cancelled), "CANCELLED");
assert_eq!(order_status_to_string(OrderStatus::Rejected), "REJECTED");
assert_eq!(string_to_order_status("NEW").unwrap(), OrderStatus::New);
assert_eq!(
string_to_order_status("FILLED").unwrap(),
OrderStatus::Filled
);
assert_eq!(
string_to_order_status("CANCELLED").unwrap(),
OrderStatus::Cancelled
);
string_to_order_status("INVALID").unwrap_err();
}
#[test]
fn test_system_status_conversions() {
assert_eq!(system_status_to_string(TliSystemStatus::Healthy), "HEALTHY");
assert_eq!(system_status_to_string(TliSystemStatus::Warning), "WARNING");
assert_eq!(
system_status_to_string(TliSystemStatus::Degraded),
"DEGRADED"
);
assert_eq!(
system_status_to_string(TliSystemStatus::Critical),
"CRITICAL"
);
assert_eq!(
string_to_system_status("HEALTHY").unwrap(),
TliSystemStatus::Healthy
);
assert_eq!(
string_to_system_status("WARNING").unwrap(),
TliSystemStatus::Warning
);
assert_eq!(
string_to_system_status("DEGRADED").unwrap(),
TliSystemStatus::Degraded
);
assert_eq!(
string_to_system_status("CRITICAL").unwrap(),
TliSystemStatus::Critical
);
string_to_system_status("INVALID").unwrap_err();
}
#[test]
fn test_symbol_validation() {
// Valid symbols
validate_symbol("AAPL").unwrap();
validate_symbol("BTC.USD").unwrap();
validate_symbol("EUR-USD").unwrap();
validate_symbol("SPX_500").unwrap();
validate_symbol("A").unwrap();
validate_symbol("123ABC").unwrap();
// Invalid symbols
assert!(validate_symbol("").is_err());
assert!(validate_symbol(&"A".repeat(21)).is_err());
assert!(validate_symbol("BTC/USD").is_err()); // slash not allowed
assert!(validate_symbol("BTC USD").is_err()); // space not allowed
assert!(validate_symbol("BTC@USD").is_err()); // special chars not allowed
}
#[test]
fn test_quantity_validation() {
// Valid quantities
validate_quantity(1.0).unwrap();
validate_quantity(0.0001).unwrap();
validate_quantity(1000000.0).unwrap();
// Invalid quantities
assert!(validate_quantity(0.0).is_err());
assert!(validate_quantity(-1.0).is_err());
assert!(validate_quantity(f64::NAN).is_err());
assert!(validate_quantity(f64::INFINITY).is_err());
assert!(validate_quantity(f64::NEG_INFINITY).is_err());
}
#[test]
fn test_price_validation() {
// Valid prices
validate_price(1.0).unwrap();
validate_price(0.01).unwrap();
validate_price(999999.99).unwrap();
// Invalid prices
assert!(validate_price(0.0).is_err());
assert!(validate_price(-1.0).is_err());
assert!(validate_price(f64::NAN).is_err());
assert!(validate_price(f64::INFINITY).is_err());
assert!(validate_price(f64::NEG_INFINITY).is_err());
}
#[test]
fn test_create_proto_position() {
let position = create_proto_position("AAPL".to_owned(), 100.0, 150.0, 140.0);
assert_eq!(position.symbol, "AAPL");
assert_eq!(position.quantity, 100.0);
assert_eq!(position.market_price, 150.0);
assert_eq!(position.market_value, 15000.0);
assert_eq!(position.average_cost, 140.0);
assert_eq!(position.unrealized_pnl, 1000.0); // (150-140) * 100
assert_eq!(position.realized_pnl, 0.0);
}
#[test]
fn test_create_metric() {
use std::collections::HashMap;
let labels = HashMap::from([
("service".to_owned(), "test".to_owned()),
("environment".to_owned(), "dev".to_owned()),
]);
let metric = create_metric(
"test_metric".to_owned(),
42.5,
"count".to_owned(),
labels.clone(),
);
assert_eq!(metric.name, "test_metric");
assert_eq!(metric.value, 42.5);
assert_eq!(metric.unit, "count");
assert_eq!(metric.labels, labels);
assert!(metric.timestamp_unix_nanos > 0);
}
}
mod error_tests {
use super::*;
#[test]
fn test_error_types() {
let connection_error = TliError::Connection("Connection failed".to_owned());
let invalid_request_error = TliError::InvalidRequest("Bad request".to_owned());
let invalid_symbol_error = TliError::InvalidSymbol("Bad symbol".to_owned());
let not_connected_error = TliError::Connection("Not connected".to_owned());
// Test Display implementation
assert!(connection_error.to_string().contains("Connection failed"));
assert!(invalid_request_error.to_string().contains("Bad request"));
assert!(invalid_symbol_error.to_string().contains("Bad symbol"));
assert!(not_connected_error.to_string().contains("Not connected"));
// Test Debug implementation
assert!(!format!("{:?}", connection_error).is_empty());
assert!(!format!("{:?}", invalid_request_error).is_empty());
}
#[test]
#[ignore = "std::io::Error From conversion not implemented"]
fn test_error_from_conversions() {
// let std_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
// let tli_error: TliError = std_error.into();
// match tli_error {
// TliError::Connection(msg) => assert!(msg.contains("File not found")),
// _ => panic!("Expected Connection error"),
// }
}
}
// Property-based tests
proptest! {
#[test]
fn test_timestamp_conversion_property(timestamp in 0_i64..i64::MAX/2) {
let system_time = unix_nanos_to_system_time(timestamp);
let converted = system_time_to_unix_nanos(system_time);
// Allow for small rounding errors
prop_assert!((converted - timestamp).abs() < 1000); // Within 1 microsecond
}
#[test]
fn test_symbol_validation_property(symbol in "[A-Za-z0-9._-]{1,20}") {
prop_assert!(validate_symbol(&symbol).is_ok());
}
#[test]
fn test_quantity_validation_property(quantity in 0.0001_f64..1000000.0) {
prop_assert!(validate_quantity(quantity).is_ok());
}
#[test]
fn test_price_validation_property(price in 0.01_f64..999999.99) {
prop_assert!(validate_price(price).is_ok());
}
#[test]
fn test_position_calculation_property(
quantity in -1000.0_f64..1000.0,
market_price in 0.01_f64..10000.0,
average_cost in 0.01_f64..10000.0
) {
let position = create_proto_position(
"TEST".to_owned(),
quantity,
market_price,
average_cost,
);
prop_assert_eq!(position.quantity, quantity);
prop_assert_eq!(position.market_price, market_price);
prop_assert_eq!(position.average_cost, average_cost);
prop_assert_eq!(position.market_value, quantity * market_price);
// Use approximate comparison for floating point PnL calculation
let expected_pnl = (market_price - average_cost) * quantity;
let diff = (position.unrealized_pnl - expected_pnl).abs();
prop_assert!(diff < 0.0001, "PnL difference {} too large", diff);
}
}
#[cfg(test)]
mod integration_helpers {
use std::sync::Once;
static INIT: Once = Once::new();
pub(super) fn setup_test_environment() {
INIT.call_once(|| {
// Initialize logging for tests
// Simplified logging setup
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
// Set test environment variables
std::env::set_var("RUST_LOG", "info");
std::env::set_var("TLI_TEST_MODE", "1");
});
}
pub(super) fn cleanup_test_environment() {
// Clean up any test-specific environment variables
std::env::remove_var("TLI_TEST_MODE");
}
}
#[cfg(test)]
mod benchmark_helpers {
use super::*;
use std::time::Instant;
pub(super) fn measure_time<F, R>(f: F) -> (R, std::time::Duration)
where
F: FnOnce() -> R,
{
let start = Instant::now();
let result = f();
let duration = start.elapsed();
(result, duration)
}
#[test]
fn test_timestamp_conversion_performance() {
let iterations = 10000;
let start = Instant::now();
for i in 0..iterations {
let timestamp = (i as i64) * 1_000_000_000; // Convert to nanoseconds
let system_time = unix_nanos_to_system_time(timestamp);
let _converted = system_time_to_unix_nanos(system_time);
}
let duration = start.elapsed();
let avg_duration = duration / iterations;
// Should be fast - under 1 microsecond per conversion
assert!(
avg_duration.as_nanos() < 1000,
"Timestamp conversion too slow: {:?}",
avg_duration
);
}
#[test]
fn test_validation_performance() {
let symbols = vec!["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA"];
let iterations = 1000;
let start = Instant::now();
for _ in 0..iterations {
for symbol in &symbols {
let _ = validate_symbol(symbol);
let _ = validate_quantity(100.0);
let _ = validate_price(150.0);
}
}
let duration = start.elapsed();
let total_validations = iterations * symbols.len() * 3;
let avg_duration = duration / total_validations as u32;
// Should be very fast - under 100 nanoseconds per validation
assert!(
avg_duration.as_nanos() < 100,
"Validation too slow: {:?}",
avg_duration
);
}
}
#[cfg(test)]
mod command_handling_tests {
use crate::types::*;
#[test]
fn test_order_command_validation() {
// Valid order parameters
validate_symbol("AAPL").unwrap();
validate_quantity(100.0).unwrap();
validate_price(150.0).unwrap();
// Invalid order parameters
assert!(validate_symbol("").is_err());
assert!(validate_quantity(0.0).is_err());
assert!(validate_price(-1.0).is_err());
}
#[test]
fn test_order_side_parsing() {
assert_eq!(string_to_order_side("BUY").unwrap(), TliOrderSide::Buy);
assert_eq!(string_to_order_side("buy").unwrap(), TliOrderSide::Buy);
assert_eq!(string_to_order_side("SELL").unwrap(), TliOrderSide::Sell);
assert_eq!(string_to_order_side("sell").unwrap(), TliOrderSide::Sell);
string_to_order_side("INVALID").unwrap_err();
}
}
#[cfg(test)]
mod error_display_tests {
use crate::error::TliError;
#[test]
fn test_error_display() {
let connection_error = TliError::Connection("Connection failed".to_owned());
let error_str = connection_error.to_string();
assert!(error_str.contains("Connection failed"));
}
#[test]
fn test_error_types_comprehensive() {
let errors = vec![
TliError::Connection("conn".to_owned()),
TliError::InvalidRequest("req".to_owned()),
TliError::InvalidSymbol("sym".to_owned()),
TliError::Config("config".to_owned()),
TliError::Dashboard("dashboard".to_owned()),
TliError::BufferFull("full".to_owned()),
TliError::NotFound("not_found".to_owned()),
TliError::Other("other".to_owned()),
];
for error in errors {
// All errors should have meaningful display
assert!(!error.to_string().is_empty());
// All errors should have debug output
assert!(!format!("{:?}", error).is_empty());
}
}
}

View File

@@ -1,582 +0,0 @@
//! Type conversions and utilities for TLI gRPC services
use crate::error::{TliError, TliResult};
use crate::proto::trading::ServiceStatus;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
// Simplified imports to avoid core dependency issues
// Removed unused imports: rust_decimal::Decimal, common::Price, common::Quantity, common::Timestamp, common::OrderSide
// Define basic types locally until core is available
// Define local types for TLI use (avoiding complex core dependencies)
/// System status enumeration for TLI services
///
/// Represents the operational state of trading system services from healthy
/// operation to critical failures requiring immediate attention.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TliSystemStatus {
/// System is operating normally with all components functional
Healthy,
/// System is operational but showing warning indicators
Warning,
/// System is experiencing reduced functionality or performance
Degraded,
/// System is in critical state requiring immediate intervention
Critical,
}
/// Order side enumeration for buy/sell operations
///
/// Represents the direction of a trading order in the financial markets.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum TliOrderSide {
/// Buy order - purchasing an asset
Buy,
/// Sell order - selling an asset
Sell,
}
/// Metric data structure for monitoring and observability
///
/// Contains time-series metric data with metadata labels for comprehensive
/// system monitoring and performance tracking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TliMetric {
/// Name of the metric (e.g., "latency", "throughput")
pub name: String,
/// Numeric value of the metric measurement
pub value: f64,
/// Unit of measurement (e.g., "ms", "ops/sec", "bytes")
pub unit: String,
/// Key-value labels for metric categorization and filtering
pub labels: HashMap<String, String>,
/// Timestamp when the metric was recorded (Unix nanoseconds)
pub timestamp_unix_nanos: i64,
}
/// Service status information for system health monitoring
///
/// Provides comprehensive status information about individual trading
/// system services including operational state and diagnostic data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TliServiceStatus {
/// Name of the service (e.g., "`trading_engine`", "`risk_manager`")
pub service_name: String,
/// Current operational status of the service
pub status: TliSystemStatus,
/// Service uptime in seconds since last restart
pub uptime_seconds: f64,
/// Last error message if the service encountered issues
pub last_error: Option<String>,
}
// Use protobuf types for protocol communication
use crate::proto::trading::{
OrderStatus as ProtoOrderStatus, OrderType as ProtoOrderType, Position as ProtoPosition,
};
/// Convert Unix nanoseconds to `SystemTime`
///
/// Converts a Unix timestamp in nanoseconds to a Rust `SystemTime` instance.
///
/// Returns `UNIX_EPOCH` for negative values to handle invalid timestamps gracefully.
///
/// # Arguments
/// * `nanos` - Unix timestamp in nanoseconds since epoch
///
/// # Returns
/// `SystemTime` instance representing the timestamp
pub fn unix_nanos_to_system_time(nanos: i64) -> SystemTime {
if nanos < 0 {
UNIX_EPOCH
} else {
UNIX_EPOCH + std::time::Duration::from_nanos(nanos as u64)
}
}
/// Convert `SystemTime` to Unix nanoseconds
///
/// Converts a Rust `SystemTime` instance to Unix nanoseconds since epoch.
///
/// Returns 0 for times before the Unix epoch.
///
/// # Arguments
/// * `time` - `SystemTime` instance to convert
///
/// # Returns
///
/// Unix timestamp in nanoseconds since epoch
pub fn system_time_to_unix_nanos(time: SystemTime) -> i64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as i64
}
/// Get current timestamp in Unix nanoseconds
///
/// Returns the current system time as Unix nanoseconds since epoch.
///
/// Useful for timestamping events and metrics in the trading system.
///
/// # Returns
///
/// Current Unix timestamp in nanoseconds
pub fn current_unix_nanos() -> i64 {
system_time_to_unix_nanos(SystemTime::now())
}
/// Convert `OrderSide` to string representation
///
/// Converts a TLI order side enumeration to its standard string representation
/// used in trading protocols and APIs.
///
/// # Arguments
/// * `side` - The order side to convert
///
/// # Returns
///
/// String representation ("BUY" or "SELL")
pub const fn order_side_to_string(side: TliOrderSide) -> &'static str {
match side {
TliOrderSide::Buy => "BUY",
TliOrderSide::Sell => "SELL",
}
}
/// Convert string to `OrderSide`
///
/// Parses a string representation of an order side into the TLI enumeration.
///
/// Case-insensitive parsing supports both "BUY"/"SELL" and "buy"/"sell".
///
/// # Arguments
/// * `side` - String representation of order side
///
/// # Returns
/// `TliResult<TliOrderSide>` - Parsed order side or error for invalid input
///
/// # Errors
///
/// Returns `TliError::InvalidRequest` for unrecognized order side strings
pub fn string_to_order_side(side: &str) -> TliResult<TliOrderSide> {
match side.to_uppercase().as_str() {
"BUY" => Ok(TliOrderSide::Buy),
"SELL" => Ok(TliOrderSide::Sell),
_ => Err(TliError::InvalidRequest(format!(
"Invalid order side: {}",
side
))),
}
}
/// Convert protobuf `OrderType` to string representation
///
/// Converts a protobuf `OrderType` enumeration to its standard string representation
/// used in trading APIs and user interfaces.
///
/// # Arguments
/// * `order_type` - The protobuf `OrderType` to convert
///
/// # Returns
///
/// String representation of the order type
pub const fn order_type_to_string(order_type: ProtoOrderType) -> &'static str {
match order_type {
ProtoOrderType::Market => "MARKET",
ProtoOrderType::Limit => "LIMIT",
ProtoOrderType::Stop => "STOP",
ProtoOrderType::StopLimit => "STOP_LIMIT",
ProtoOrderType::Unspecified => "UNSPECIFIED",
}
}
/// Convert string to protobuf `OrderType`
///
/// Parses a string representation into the corresponding protobuf `OrderType`.
///
/// Used for converting user input and API requests into internal representations.
///
/// # Arguments
/// * `order_type` - String representation of order type
///
/// # Returns
/// `TliResult<ProtoOrderType>` - Parsed order type or error for invalid input
///
/// # Errors
///
/// Returns `TliError::InvalidRequest` for unrecognized order type strings
pub fn string_to_order_type(order_type: &str) -> TliResult<ProtoOrderType> {
match order_type {
"MARKET" => Ok(ProtoOrderType::Market),
"LIMIT" => Ok(ProtoOrderType::Limit),
"STOP" => Ok(ProtoOrderType::Stop),
"STOP_LIMIT" => Ok(ProtoOrderType::StopLimit),
_ => Err(TliError::InvalidRequest(format!(
"Invalid order type: {}",
order_type
))),
}
}
/// Convert protobuf `OrderStatus` to string representation
///
/// Converts a protobuf `OrderStatus` enumeration to its standard string representation
/// used in trading APIs and order management systems.
///
/// # Arguments
/// * `status` - The protobuf `OrderStatus` to convert
///
/// # Returns
///
/// String representation of the order status
pub const fn order_status_to_string(status: ProtoOrderStatus) -> &'static str {
match status {
ProtoOrderStatus::New => "NEW",
ProtoOrderStatus::PartiallyFilled => "PARTIALLY_FILLED",
ProtoOrderStatus::Filled => "FILLED",
ProtoOrderStatus::Cancelled => "CANCELLED",
ProtoOrderStatus::Rejected => "REJECTED",
ProtoOrderStatus::PendingCancel => "PENDING_CANCEL",
ProtoOrderStatus::Unspecified => "UNSPECIFIED",
}
}
/// Convert string to protobuf `OrderStatus`
///
/// Parses a string representation into the corresponding protobuf `OrderStatus`.
///
/// Used for processing order status updates from trading venues and APIs.
///
/// # Arguments
/// * `status` - String representation of order status
///
/// # Returns
/// `TliResult<ProtoOrderStatus>` - Parsed order status or error for invalid input
///
/// # Errors
///
/// Returns `TliError::InvalidRequest` for unrecognized order status strings
pub fn string_to_order_status(status: &str) -> TliResult<ProtoOrderStatus> {
match status {
"NEW" => Ok(ProtoOrderStatus::New),
"PARTIALLY_FILLED" => Ok(ProtoOrderStatus::PartiallyFilled),
"FILLED" => Ok(ProtoOrderStatus::Filled),
"CANCELLED" => Ok(ProtoOrderStatus::Cancelled),
"REJECTED" => Ok(ProtoOrderStatus::Rejected),
"PENDING_CANCEL" => Ok(ProtoOrderStatus::PendingCancel),
_ => Err(TliError::InvalidRequest(format!(
"Invalid order status: {}",
status
))),
}
}
/// Convert TLI `SystemStatus` to string representation
///
/// Converts a TLI `SystemStatus` enumeration to its standard string representation
/// used in system monitoring and health check APIs.
///
/// # Arguments
/// * `status` - The TLI `SystemStatus` to convert
///
/// # Returns
///
/// String representation of the system status
pub const fn system_status_to_string(status: TliSystemStatus) -> &'static str {
match status {
TliSystemStatus::Healthy => "HEALTHY",
TliSystemStatus::Warning => "WARNING",
TliSystemStatus::Degraded => "DEGRADED",
TliSystemStatus::Critical => "CRITICAL",
}
}
/// Convert string to TLI `SystemStatus`
///
/// Parses a string representation into the corresponding TLI `SystemStatus`.
///
/// Used for processing health check responses and monitoring system states.
///
/// # Arguments
/// * `status` - String representation of system status
///
/// # Returns
/// `TliResult<TliSystemStatus>` - Parsed system status or error for invalid input
///
/// # Errors
///
/// Returns `TliError::InvalidRequest` for unrecognized system status strings
pub fn string_to_system_status(status: &str) -> TliResult<TliSystemStatus> {
match status {
"HEALTHY" => Ok(TliSystemStatus::Healthy),
"WARNING" => Ok(TliSystemStatus::Warning),
"DEGRADED" => Ok(TliSystemStatus::Degraded),
"CRITICAL" => Ok(TliSystemStatus::Critical),
_ => Err(TliError::InvalidRequest(format!(
"Invalid system status: {}",
status
))),
}
}
/// Validate symbol format
///
/// Validates that a trading symbol meets the required format constraints.
///
/// Ensures symbols are properly formatted for use in trading operations.
///
/// # Arguments
/// * `symbol` - The trading symbol to validate
///
/// # Returns
/// `TliResult<()>` - Ok if valid, error describing validation failure
///
/// # Errors
/// - `TliError::InvalidSymbol` if symbol is empty, too long, or contains invalid characters
///
/// # Validation Rules
/// - Symbol must not be empty
///
/// - Symbol must be 20 characters or less
/// - Symbol must contain only alphanumeric characters and separators (., -, _)
pub fn validate_symbol(symbol: &str) -> TliResult<()> {
if symbol.is_empty() {
return Err(TliError::InvalidSymbol("Symbol cannot be empty".to_owned()));
}
if symbol.len() > 20 {
return Err(TliError::InvalidSymbol(
"Symbol too long (max 20 characters)".to_owned(),
));
}
// Basic symbol validation - alphanumeric plus some common separators
if !symbol
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
{
return Err(TliError::InvalidSymbol(
"Symbol contains invalid characters".to_owned(),
));
}
Ok(())
}
/// Validate quantity for trading operations
///
/// Validates that a trading quantity is positive and finite.
///
/// Prevents invalid order quantities that could cause trading errors.
///
/// # Arguments
/// * `quantity` - The quantity to validate
///
/// # Returns
/// `TliResult<()>` - Ok if valid, error describing validation failure
///
/// # Errors
/// - `TliError::InvalidRequest` if quantity is not positive or not finite
pub fn validate_quantity(quantity: f64) -> TliResult<()> {
if quantity <= 0.0 {
return Err(TliError::InvalidRequest(
"Quantity must be positive".to_owned(),
));
}
if !quantity.is_finite() {
return Err(TliError::InvalidRequest(
"Quantity must be finite".to_owned(),
));
}
Ok(())
}
/// Validate price for trading operations
///
/// Validates that a trading price is positive and finite.
///
/// Prevents invalid order prices that could cause trading errors.
///
/// # Arguments
/// * `price` - The price to validate
///
/// # Returns
/// `TliResult<()>` - Ok if valid, error describing validation failure
///
/// # Errors
/// - `TliError::InvalidRequest` if price is not positive or not finite
pub fn validate_price(price: f64) -> TliResult<()> {
if price <= 0.0 {
return Err(TliError::InvalidRequest(
"Price must be positive".to_owned(),
));
}
if !price.is_finite() {
return Err(TliError::InvalidRequest("Price must be finite".to_owned()));
}
Ok(())
}
/// Create a metric with current timestamp
///
/// Creates a new `TliMetric` instance with the current timestamp automatically applied.
///
/// Useful for recording system metrics and performance measurements.
///
/// # Arguments
/// * `name` - Name of the metric (e.g., "latency", "throughput")
///
/// * `value` - Numeric value of the measurement
/// * `unit` - Unit of measurement (e.g., "ms", "ops/sec")
///
/// * `labels` - Key-value pairs for metric categorization
///
/// # Returns
/// `TliMetric` instance with current timestamp
pub fn create_metric(
name: String,
value: f64,
unit: String,
labels: HashMap<String, String>,
) -> TliMetric {
TliMetric {
name,
value,
unit,
labels,
timestamp_unix_nanos: current_unix_nanos(),
}
}
/// Create a protobuf position from individual fields
///
/// Creates a `ProtoPosition` instance with calculated market value and unrealized P&L.
///
/// Used for building position responses and portfolio summaries.
///
/// # Arguments
/// * `symbol` - Trading symbol for the position
///
/// * `quantity` - Number of shares/units held
/// * `market_price` - Current market price per unit
///
/// * `average_cost` - Average cost basis per unit
///
/// # Returns
/// `ProtoPosition` with calculated market value and unrealized P&L
pub fn create_proto_position(
symbol: String,
quantity: f64,
market_price: f64,
average_cost: f64,
) -> ProtoPosition {
let market_value = quantity * market_price;
let unrealized_pnl = market_value - (quantity * average_cost);
ProtoPosition {
symbol,
quantity,
market_price,
market_value,
average_cost,
unrealized_pnl,
realized_pnl: 0.0, // This would come from trade history
}
}
/// Create a service status entry
///
/// Creates a `ServiceStatus` protobuf message with current timestamp.
///
/// Used for health check responses and system monitoring.
///
/// # Arguments
/// * `name` - Name of the service being reported
///
/// * `status` - Current operational status
/// * `message` - Human-readable status message
///
/// * `details` - Additional key-value diagnostic information
///
/// # Returns
/// `ServiceStatus` protobuf message with current timestamp
pub fn create_service_status(
name: String,
status: TliSystemStatus,
message: String,
details: HashMap<String, String>,
) -> ServiceStatus {
ServiceStatus {
name,
status: status as i32,
message,
last_check_unix_nanos: current_unix_nanos(),
details,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timestamp_conversion() {
let now = SystemTime::now();
let nanos = system_time_to_unix_nanos(now);
let converted = unix_nanos_to_system_time(nanos);
// Allow for small timing differences
let diff = now
.duration_since(converted)
.unwrap_or_else(|_| converted.duration_since(now).unwrap());
assert!(diff.as_millis() < 1);
}
#[test]
fn test_order_side_conversion() {
assert_eq!(order_side_to_string(TliOrderSide::Buy), "BUY");
assert_eq!(order_side_to_string(TliOrderSide::Sell), "SELL");
assert_eq!(string_to_order_side("BUY").unwrap(), TliOrderSide::Buy);
assert_eq!(string_to_order_side("buy").unwrap(), TliOrderSide::Buy);
string_to_order_side("INVALID").unwrap_err();
}
#[test]
fn test_symbol_validation() {
validate_symbol("AAPL").unwrap();
validate_symbol("BTC.USD").unwrap();
validate_symbol("EUR-USD").unwrap();
validate_symbol("SPX_500").unwrap();
assert!(validate_symbol("").is_err());
assert!(validate_symbol("A".repeat(21).as_str()).is_err());
assert!(validate_symbol("BTC/USD").is_err()); // slash not allowed
}
#[test]
fn test_quantity_validation() {
validate_quantity(1.0).unwrap();
validate_quantity(0.0001).unwrap();
assert!(validate_quantity(0.0).is_err());
assert!(validate_quantity(-1.0).is_err());
assert!(validate_quantity(f64::NAN).is_err());
assert!(validate_quantity(f64::INFINITY).is_err());
}
#[test]
fn test_create_position() {
let position = create_proto_position("AAPL".to_owned(), 100.0, 150.0, 140.0);
assert_eq!(position.symbol, "AAPL");
assert_eq!(position.quantity, 100.0);
assert_eq!(position.market_price, 150.0);
assert_eq!(position.market_value, 15000.0);
assert_eq!(position.average_cost, 140.0);
assert_eq!(position.unrealized_pnl, 1000.0); // (150-140) * 100
}
}