refactor(tli): remove Ratatui dashboards, widgets, streaming stubs (14,235 lines)

Delete TLI terminal UI code replaced by web-dashboard architecture:
- dashboard/ (11 files): trading, risk, ML, performance, backtesting, config, events, vault
- dashboards/ (3 files): config manager, configuration
- ui/ (8 files): widgets (candlestick, order book, risk gauge, sparkline, PnL heatmap, config form)
- events/ (4 files): aggregator, event buffer, stream manager
- client stubs: data_stream, event_stream, stream_manager
- error_consolidated.rs, 4 examples, market_data_edge_cases test

Update lib.rs, prelude.rs, main.rs, client/mod.rs, tests.rs to remove references.
Remove ratatui, crossterm, adaptive-strategy dependencies from Cargo.toml.
Clean up test fixtures (TestEventPublisher removed).

TLI retains all CLI commands (tune, train, auth, agent, backtest, trade).
134 tests passing, 0 warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-21 23:50:50 +01:00
parent 77ad1530cd
commit e364d447f5
44 changed files with 33 additions and 15692 deletions

174
Cargo.lock generated
View File

@@ -1963,27 +1963,12 @@ dependencies = [
"log",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.40"
@@ -2304,20 +2289,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -2639,10 +2610,7 @@ dependencies = [
"bitflags 2.9.4",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot 0.12.5",
"signal-hook",
"signal-hook-mio",
"winapi",
]
@@ -2653,13 +2621,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.9.4",
"crossterm_winapi",
"mio 1.0.4",
"parking_lot 0.12.5",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
@@ -2767,18 +2730,8 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
dependencies = [
"darling_core 0.14.4",
"darling_macro 0.14.4",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
"darling_core",
"darling_macro",
]
[[package]]
@@ -2795,42 +2748,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
dependencies = [
"darling_core 0.14.4",
"darling_core",
"quote",
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.106",
]
[[package]]
name = "dashmap"
version = "5.5.3"
@@ -3174,7 +3102,7 @@ version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f"
dependencies = [
"darling 0.14.4",
"darling",
"proc-macro2",
"quote",
"syn 1.0.109",
@@ -4873,12 +4801,6 @@ dependencies = [
"web-time",
]
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "influxdb"
version = "0.7.2"
@@ -4965,19 +4887,6 @@ dependencies = [
"similar",
]
[[package]]
name = "instability"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
dependencies = [
"darling 0.20.11",
"indoc",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -5612,18 +5521,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536"
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.4"
@@ -5631,7 +5528,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -7546,27 +7442,6 @@ dependencies = [
"serde",
]
[[package]]
name = "ratatui"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
"bitflags 2.9.4",
"cassowary",
"compact_str",
"crossterm 0.28.1",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.1.14",
]
[[package]]
name = "rav1e"
version = "0.7.1"
@@ -8707,28 +8582,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio 0.8.11",
"mio 1.0.4",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.6"
@@ -9329,9 +9182,6 @@ name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
@@ -9883,7 +9733,6 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
name = "tli"
version = "1.0.0"
dependencies = [
"adaptive-strategy",
"aes-gcm",
"anyhow",
"argon2",
@@ -9897,7 +9746,6 @@ dependencies = [
"common",
"console",
"criterion",
"crossterm 0.27.0",
"dirs 5.0.1",
"futures",
"futures-util",
@@ -9912,7 +9760,6 @@ dependencies = [
"proptest",
"prost 0.14.1",
"rand 0.8.5",
"ratatui",
"rpassword",
"rust_decimal",
"serde",
@@ -9945,7 +9792,7 @@ dependencies = [
"bytes",
"io-uring",
"libc",
"mio 1.0.4",
"mio",
"parking_lot 0.12.5",
"pin-project-lite",
"signal-hook-registry",
@@ -10876,17 +10723,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"

106
tests/fixtures/mod.rs vendored
View File

@@ -27,7 +27,7 @@
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU16, AtomicU64, Ordering},
atomic::{AtomicU16, Ordering},
Arc,
};
use std::time::Duration;
@@ -35,12 +35,11 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde_json::json;
use tokio::sync::{mpsc, Mutex, RwLock};
use tokio::sync::RwLock;
use uuid::Uuid;
// Import TLI types explicitly (tli crate is available as dependency)
use tli::error::{TliError, TliResult};
use tli::events::{Event, EventSeverity, EventType};
use tli::error::TliResult;
// Re-export sub-modules for easy access
pub mod builders;
@@ -628,100 +627,7 @@ lazy_static::lazy_static! {
pub static ref TEST_PORT_MANAGER: TestPortManager = TestPortManager::new();
}
/// Test event publisher for streaming tests
#[derive(Debug)]
pub struct TestEventPublisher {
_event_sender: mpsc::UnboundedSender<Event>,
event_receiver: Arc<Mutex<mpsc::UnboundedReceiver<Event>>>,
published_events: AtomicU64,
}
impl TestEventPublisher {
pub async fn new() -> TliResult<Self> {
let (sender, receiver) = mpsc::unbounded_channel();
Ok(Self {
_event_sender: sender,
event_receiver: Arc::new(Mutex::new(receiver)),
published_events: AtomicU64::new(0),
})
}
pub async fn publish_event(&self, event: Event) -> TliResult<()> {
self._event_sender
.send(event)
.map_err(|e| TliError::Other(format!("Failed to publish event: {}", e)))?;
self.published_events.fetch_add(1, Ordering::Relaxed);
Ok(())
}
pub async fn publish_market_data_burst(&self, symbol: &str, count: usize) -> TliResult<()> {
for i in 0..count {
let event = Event {
id: Uuid::new_v4(),
event_type: EventType::MarketData,
severity: EventSeverity::Info,
source: "test_publisher".to_string(),
timestamp_nanos: Utc::now().timestamp_nanos_opt().unwrap_or(0),
sequence: i as u64,
payload: json!({
"symbol": symbol,
"price": 150.0 + (i as f64 * 0.01),
"volume": 100 + i,
"sequence": i
}),
correlation_id: None,
metadata: HashMap::new(),
ttl_seconds: 0,
};
self.publish_event(event).await?;
}
Ok(())
}
pub async fn publish_order_lifecycle(&self, order_id: &str) -> TliResult<()> {
let states = vec!["pending", "partially_filled", "filled"];
for (i, state) in states.into_iter().enumerate() {
let event = Event {
id: Uuid::new_v4(),
event_type: EventType::Trading,
severity: EventSeverity::Info,
source: "test_lifecycle".to_string(),
timestamp_nanos: Utc::now().timestamp_nanos_opt().unwrap_or(0),
sequence: i as u64,
payload: json!({
"order_id": order_id,
"status": state,
"filled_quantity": (i + 1) * 50,
"remaining_quantity": 100 - ((i + 1) * 50)
}),
correlation_id: None,
metadata: HashMap::new(),
ttl_seconds: 0,
};
self.publish_event(event).await?;
// Small delay between state changes
tokio::time::sleep(Duration::from_millis(100)).await;
}
Ok(())
}
pub fn get_published_count(&self) -> u64 {
self.published_events.load(Ordering::Relaxed)
}
pub async fn receive_event(&self) -> Option<Event> {
let mut receiver = self.event_receiver.lock().await;
receiver.recv().await
}
}
// TestEventPublisher removed — event streaming moved to web-gateway WebSocket infrastructure
/// Performance metrics collector for tests
#[derive(Debug, Default)]
@@ -858,7 +764,6 @@ pub struct TestEnvironment {
pub config: IntegrationTestConfig,
pub metrics: Arc<TestMetricsCollector>,
pub port_manager: Arc<TestPortManager>,
pub event_publisher: Arc<TestEventPublisher>,
cleanup_tasks: Vec<
Box<
dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
@@ -874,7 +779,6 @@ impl std::fmt::Debug for TestEnvironment {
.field("config", &self.config)
.field("metrics", &self.metrics)
.field("port_manager", &self.port_manager)
.field("event_publisher", &self.event_publisher)
.field(
"cleanup_tasks",
&format!("<{} cleanup tasks>", self.cleanup_tasks.len()),
@@ -887,13 +791,11 @@ impl TestEnvironment {
pub async fn new(config: IntegrationTestConfig) -> TliResult<Self> {
let metrics = Arc::new(TestMetricsCollector::new());
let port_manager = Arc::new(TestPortManager::new());
let event_publisher = Arc::new(TestEventPublisher::new().await?);
Ok(Self {
config,
metrics,
port_manager,
event_publisher,
cleanup_tasks: Vec::new(),
})
}

View File

@@ -8,9 +8,10 @@ use crate::framework::{TestOrchestrator, TestFrameworkConfig, PerformanceThresho
use crate::framework::mocks::MockServiceRegistry;
use config::{ConfigManager, TLIConfig};
use tli::client::{TLIClient, ConnectionManager, ServiceConnection};
use tli::ui::{Dashboard, Terminal, CommandInterface};
use tli::commands::{Command, CommandResult, CommandType};
// Dashboard types removed — TUI replaced by web-dashboard
// use tli::client::{TLIClient, ConnectionManager, ServiceConnection};
// use tli::ui::{Dashboard, Terminal, CommandInterface};
// use tli::commands::{Command, CommandResult, CommandType};
/// TLI Client Integration Tests
///

View File

@@ -45,17 +45,10 @@ tracing-subscriber.workspace = true
common.workspace = true
# REMOVED trading_engine dependency - violates pure client architecture
# Terminal UI framework dependencies (essential for TLI)
ratatui.workspace = true
crossterm.workspace = true
# Time and financial types for UI widgets
# Time and financial types
chrono.workspace = true
rust_decimal.workspace = true
# Import adaptive-strategy for UI widget types only (microstructure module)
adaptive-strategy.workspace = true
# Authentication dependencies
keyring = "3.6" # OS keyring integration for secure token storage
rpassword = "7.3" # Secure password input

View File

@@ -1,10 +0,0 @@
//! Basic dashboard disabled - needs refactoring after client architecture changes
//!
//! This example referenced old client types that were removed when TLI was refactored.
//!
//! To re-enable: Update to use current client API and event types
#![allow(unused_crate_dependencies)]
fn main() {
println!("Basic dashboard disabled - needs refactoring for current client API");
}

View File

@@ -1,78 +0,0 @@
//! Configuration Dashboard Demo
//!
//! This example demonstrates the TLI Configuration Dashboard functionality.
//! Note: Configuration management is now handled by the shared config crate,
//! not directly by TLI which is a pure client.
#![allow(unused_crate_dependencies)]
use tli::prelude::*;
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> TliResult<()> {
println!("🚀 TLI Configuration Dashboard Demo");
println!("=====================================");
// Create a mock event channel
let (_event_sender, _event_receiver) = mpsc::channel(100);
// Create the configuration dashboard
let config_dashboard = tli::dashboards::ConfigurationDashboard::new(_event_sender);
println!("✅ Configuration Dashboard created successfully!");
// Note: Actual configuration management is done through the config crate
// which TLI accesses via gRPC services, not directly
println!(
"
Note: Configuration is managed by the config crate and accessed via gRPC.
TLI is a pure client that doesn't directly manage configuration."
);
let _ = config_dashboard; // Use the dashboard to avoid unused warning
println!("\n📋 Configuration Dashboard Features:");
println!(" 🌳 Hierarchical category tree navigation");
println!(" ⚙️ Real-time configuration editing");
println!(" ✅ Live validation with error reporting");
println!(" 📜 Change history with rollback capability");
println!(" 🔍 Search functionality across settings");
println!(" 🔥 Hot-reload indicator for immediate changes");
println!(" 🔐 Sensitive value masking");
println!(" 🎯 Environment-specific configurations");
println!("\n🎮 Keyboard Controls:");
println!(" [Tab] - Switch between panels");
println!(" [↑↓] - Navigate lists");
println!(" [Enter] - Select/Edit");
println!(" [Space] - Toggle category expansion");
println!(" [E] - Edit current setting");
println!(" [S] - Search settings");
println!(" [R] - Reset to default");
println!(" [F5] - Refresh data");
println!(" [Esc] - Cancel/Exit");
println!("\n🏗️ Architecture:");
println!(" Client: TLI Terminal Application");
println!(" Database: PostgreSQL with config schema");
println!(" UI: Ratatui terminal interface");
println!(" Validation: Real-time with custom rules");
println!("\n🎯 Dashboard Layout:");
println!("┌─ CONFIGURATION DASHBOARD ────────────────────────────────────────┐");
println!("│ Category Tree │ Settings Editor │ Validation & History │");
println!("│ ▼ System │ Key: log_level │ Status: ✓ VALID │");
println!("│ ├─ Logging │ Value: [info ▼] │ Type: string │");
println!("│ ├─ Database │ Description: │ Required: Yes │");
println!("│ └─ gRPC │ Global log level │ Hot Reload: Yes │");
println!("│ ▼ Trading │ │ │");
println!("│ ├─ Execution │ [SAVE CHANGES] │ Recent Changes: │");
println!("│ ├─ Strategies │ [RESET] │ 14:30 - risk.var_conf │");
println!("│ └─ Position │ [VALIDATE] │ 14:25 - ml.model_thresh │");
println!("└──────────────────┴──────────────────┴──────────────────────────┘");
println!("\n✨ Demo completed successfully!");
println!("📌 To run with real database: Set DATABASE_URL environment variable");
Ok(())
}

View File

@@ -1,10 +0,0 @@
//! Event streaming demo disabled - needs refactoring after client architecture changes
//!
//! This example referenced old event types that were removed when TLI was refactored.
//!
//! To re-enable: Update to use current EventType variants and streaming APIs
#![allow(unused_crate_dependencies)]
fn main() {
println!("Event streaming demo disabled - needs refactoring for current event system");
}

View File

@@ -1,17 +0,0 @@
//! Real-time streaming example disabled - needs refactoring
//!
//! This example referenced proto types (Order, OrderUpdate, MetricValue, StreamOrderUpdatesRequest)
//! and TliClient that are not available in current implementation.
//!
//! To re-enable:
//! 1. Check tli::proto::trading for actual available types
//! 2. Use correct client types from tli::client modules
//! 3. Update streaming API calls to match current implementation
//! 4. Add missing std::time::UNIX_EPOCH import
#![allow(unused_crate_dependencies)]
fn main() {
println!(
"Real-time streaming example disabled - needs refactoring for current proto definitions"
);
}

View File

@@ -1,114 +0,0 @@
//! Data streaming manager for high-performance market data processing
//!
//! This module provides low-latency data streaming capabilities for processing
//! market data feeds, order book updates, and trade executions with configurable
//! buffering and latency optimization.
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
/// Configuration parameters for data stream management
///
/// Controls performance characteristics of data streams including buffer sizes,
/// latency requirements, and processing options for optimal throughput.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataStreamConfig {
/// Size of the internal data buffer (number of messages)
pub buffer_size: usize,
/// Maximum acceptable latency in milliseconds for data processing
pub max_latency_ms: u64,
}
/// High-performance data stream manager
///
/// Manages multiple concurrent data streams with optimized buffering and
/// low-latency processing. Handles market data feeds, order updates, and
/// real-time trading events with microsecond precision timing.
pub struct DataStreamManager {
/// Stream configuration parameters
#[allow(dead_code)]
config: DataStreamConfig,
/// Channel sender for outgoing data
#[allow(dead_code)]
sender: mpsc::Sender<Vec<u8>>,
/// Channel receiver for incoming data
#[allow(dead_code)]
receiver: mpsc::Receiver<Vec<u8>>,
}
impl DataStreamManager {
/// Create a new data stream manager with the specified configuration
///
/// # Arguments
/// * `config` - Stream configuration including buffer size and latency requirements
///
/// # Returns
///
/// New `DataStreamManager` instance ready for high-performance data processing
///
/// # Example
/// ```rust,no_run
/// use tli::client::data_stream::{DataStreamManager, DataStreamConfig};
///
/// let config = DataStreamConfig {
/// buffer_size: 10000,
/// max_latency_ms: 1,
/// };
/// let stream_manager = DataStreamManager::new(config);
/// ```
pub fn new(config: DataStreamConfig) -> Self {
let (sender, receiver) = mpsc::channel(config.buffer_size);
Self {
config,
sender,
receiver,
}
}
/// Start the data stream processing
///
/// Initializes all data streams and begins processing incoming market data.
///
/// Must be called before any data can be processed through the streams.
///
/// # Returns
/// `Result<(), String>` - Ok if streams start successfully
///
/// # Errors
///
/// Returns error message if stream initialization fails
pub async fn start(&mut self) -> Result<(), String> {
Ok(())
}
/// Stop all data stream processing
///
/// Gracefully shuts down all active data streams and flushes any
/// remaining data in buffers. Ensures no data loss during shutdown.
///
/// # Returns
/// `Result<(), String>` - Ok if streams stop cleanly
///
/// # Errors
///
/// Returns error message if shutdown encounters issues
pub async fn stop(&mut self) -> Result<(), String> {
Ok(())
}
/// Start all configured data streams
///
/// Convenience method that starts all data streams in the correct order.
///
/// Equivalent to calling `start()` but provides more explicit naming.
///
/// # Returns
/// `Result<(), String>` - Ok if all streams start successfully
///
/// # Errors
///
/// Returns error message if any stream fails to start
pub async fn start_streams(&mut self) -> Result<(), String> {
self.start().await
}
}

View File

@@ -1,51 +0,0 @@
//! Event streaming client for real-time data processing
//!
//! This module provides event streaming capabilities for the TLI client,
//! enabling real-time consumption of market data, order updates, and
//! system events from backend services via streaming gRPC connections.
use tokio::sync::mpsc;
/// Event stream manager for handling real-time data streams
///
/// Manages streaming connections to backend services and provides
/// asynchronous event processing capabilities with configurable buffering.
///
/// Handles automatic reconnection and stream lifecycle management.
pub struct EventStreamManager {
/// Channel receiver for incoming event data
#[allow(dead_code)]
receiver: mpsc::Receiver<Vec<u8>>,
}
/// Configuration parameters for event stream management
///
/// Controls buffering behavior, connection parameters, and stream
/// processing settings for optimal performance and reliability.
pub struct EventStreamConfig {
/// Size of the internal event buffer (number of events)
pub buffer_size: usize,
}
impl EventStreamManager {
/// Create a new event stream manager with the specified configuration
///
/// # Arguments
/// * `config` - Stream configuration including buffer size and connection settings
///
/// # Returns
///
/// New `EventStreamManager` instance ready to handle event streams
///
/// # Example
/// ```rust,no_run
/// use tli::client::event_stream::{EventStreamManager, EventStreamConfig};
///
/// let config = EventStreamConfig { buffer_size: 1000 };
/// let stream_manager = EventStreamManager::new(config);
/// ```
pub fn new(_config: EventStreamConfig) -> Self {
let (_sender, receiver) = mpsc::channel(100);
Self { receiver }
}
}

View File

@@ -10,20 +10,9 @@
pub mod backtesting_client;
pub mod connection_manager;
pub mod data_stream;
pub mod event_stream;
pub mod ml_training_client;
pub mod trading_client;
// NO RE-EXPORTS: Import directly from submodules
// Use tli::client::connection_manager::{ConnectionManager, ConnectionConfig, etc.} instead
// Use tli::client::trading_client::{TradingClient, TradingClientConfig} instead
// Use tli::client::backtesting_client::{BacktestingClient, BacktestingClientConfig} instead
// Use tli::client::ml_training_client::{MLTrainingClient, MLTrainingClientConfig, etc.} instead
// Use tli::client::event_stream::{EventStreamManager, EventStreamConfig} instead
// Use tli::client::stream_manager::{DataStreamManager} instead
// Use tli::client::data_stream::{DataStreamManager, DataStreamConfig} instead
/// Service endpoints configuration
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ServiceEndpoints {

View File

@@ -1,238 +0,0 @@
//! Data Stream Manager for Real-time Dashboard Updates
//!
//! Manages real-time data streams from gRPC services to dashboard components
use crate::dashboard::events::{
DashboardEvent, MLPredictionEvent, MarketDataDisplayEvent, PredictionType, RiskMetricsEvent,
SystemStatusEvent,
};
use anyhow::Result;
use rand::Rng;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tokio::time::{interval, Duration};
pub struct DataStreamManager {
_event_sender: mpsc::Sender<DashboardEvent>,
is_running: bool,
}
impl DataStreamManager {
pub const fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
Self {
_event_sender,
is_running: false,
}
}
/// Start all data streams for real-time dashboard updates
pub async fn start_streams(&mut self) -> Result<()> {
if self.is_running {
return Ok(());
}
self.is_running = true;
// Spawn individual stream tasks
let market_data_task = self.spawn_market_data_stream();
let risk_metrics_task = self.spawn_risk_metrics_stream();
let ml_predictions_task = self.spawn_ml_predictions_stream();
let system_status_task = self.spawn_system_status_stream();
// Start all streams concurrently
tokio::try_join!(
market_data_task,
risk_metrics_task,
ml_predictions_task,
system_status_task
)?;
Ok(())
}
/// Generate mock market data stream for demo purposes
async fn spawn_market_data_stream(&self) -> Result<()> {
let mut ticker = interval(Duration::from_millis(1000));
let sender = self._event_sender.clone();
let symbols = vec!["AAPL", "TSLA", "SPY", "QQQ", "NVDA"];
let mut prices: HashMap<&str, f64> = HashMap::new();
// Initialize prices
prices.insert("AAPL", 150.25);
prices.insert("TSLA", 800.50);
prices.insert("SPY", 420.10);
prices.insert("QQQ", 350.75);
prices.insert("NVDA", 450.30);
tokio::spawn(async move {
loop {
ticker.tick().await;
// Generate all updates in a single scope without holding RNG across await
let updates: Vec<MarketDataDisplayEvent> = {
let mut rng = rand::thread_rng();
let mut updates = Vec::new();
for symbol in &symbols {
if let Some(current_price) = prices.get_mut(symbol) {
// Simulate price movement (±0.5%)
let change_pct = (rng.gen::<f64>() - 0.5) * 0.01;
*current_price *= 1.0 + change_pct;
let market_data = MarketDataDisplayEvent {
symbol: symbol.to_string(),
price: *current_price,
volume: rng.gen_range(500_000..1_500_000),
timestamp: chrono::Utc::now().timestamp(),
bid: Some(*current_price - 0.01),
ask: Some(*current_price + 0.01),
change: Some(change_pct * *current_price),
change_percent: Some(change_pct * 100.0),
};
updates.push(market_data);
}
}
updates
};
// Send all updates
for market_data in updates {
let _ = sender
.send(DashboardEvent::MarketDataUpdate(market_data))
.await;
}
}
});
Ok(())
}
/// Generate mock risk metrics stream
async fn spawn_risk_metrics_stream(&self) -> Result<()> {
let mut ticker = interval(Duration::from_millis(5000));
let sender = self._event_sender.clone();
tokio::spawn(async move {
let mut portfolio_value = 1_000_000.0;
loop {
ticker.tick().await;
// Generate risk metrics in a single scope
let risk_metrics = {
let mut rng = rand::thread_rng();
let pnl_change = (rng.gen::<f64>() - 0.5) * 5000.0;
let var_1d_rand = rng.gen::<f64>() * 1000.0;
let var_5d_rand = rng.gen::<f64>() * 1500.0;
let dd_rand = (rng.gen::<f64>() - 0.5) * 0.02;
let risk_rand = rng.gen::<f64>() * 0.4;
portfolio_value += pnl_change;
RiskMetricsEvent {
portfolio_value,
daily_pnl: pnl_change,
total_pnl: portfolio_value - 1_000_000.0,
var_1d: 5000.0 + var_1d_rand,
var_5d: 8000.0 + var_5d_rand,
max_drawdown: -0.15,
current_drawdown: -0.025 + dd_rand,
risk_score: 0.3 + risk_rand,
timestamp: chrono::Utc::now().timestamp(),
}
};
let _ = sender
.send(DashboardEvent::RiskMetricsUpdate(risk_metrics))
.await;
}
});
Ok(())
}
/// Generate mock ML predictions stream
async fn spawn_ml_predictions_stream(&self) -> Result<()> {
let mut ticker = interval(Duration::from_millis(3000));
let sender = self._event_sender.clone();
let models = vec!["DQN", "MAMBA", "TFT", "LIQUID", "TLOB", "PPO"];
let symbols = vec!["AAPL", "TSLA", "SPY"];
tokio::spawn(async move {
loop {
ticker.tick().await;
// Generate all predictions in a single scope
let predictions: Vec<MLPredictionEvent> = {
let mut rng = rand::thread_rng();
let mut predictions = Vec::new();
for model in &models {
for symbol in &symbols {
let prediction_type = match rng.gen_range(0..3) {
0 => PredictionType::Buy,
1 => PredictionType::Sell,
_ => PredictionType::Hold,
};
let ml_prediction = MLPredictionEvent {
model_name: model.to_string(),
symbol: symbol.to_string(),
prediction: prediction_type,
confidence: 0.5 + (rng.gen::<f64>() * 0.4),
signal_strength: rng.gen::<f64>(),
features: (0..10).map(|_| rng.gen::<f64>()).collect(),
timestamp: chrono::Utc::now().timestamp(),
};
predictions.push(ml_prediction);
}
}
predictions
};
// Send all predictions
for ml_prediction in predictions {
let _ = sender
.send(DashboardEvent::MLPredictionUpdate(ml_prediction))
.await;
}
}
});
Ok(())
}
/// Generate mock system status updates
async fn spawn_system_status_stream(&self) -> Result<()> {
let mut ticker = interval(Duration::from_millis(2000));
let sender = self._event_sender.clone();
tokio::spawn(async move {
loop {
ticker.tick().await;
// Generate system status in a single scope
let system_status = {
let mut rng = rand::thread_rng();
SystemStatusEvent {
trading_enabled: true,
risk_controls_active: true,
ml_models_online: 6,
total_ml_models: 6,
active_positions: rng.gen_range(1..11),
pending_orders: rng.gen_range(0..5),
timestamp: chrono::Utc::now().timestamp(),
}
};
let _ = sender
.send(DashboardEvent::SystemStatus(system_status))
.await;
}
});
Ok(())
}
pub fn stop(&mut self) {
self.is_running = false;
}
}

View File

@@ -1,564 +0,0 @@
//! Backtesting Dashboard - Strategy Testing and Historical Analysis
//!
//! This dashboard provides comprehensive backtesting functionality including:
//! - Active backtest monitoring with real-time progress
//! - Historical backtest results and performance analysis
//! - Strategy configuration and parameter management
//! - Performance metrics visualization (returns, Sharpe ratio, drawdown)
//! - Trade execution analysis and order flow
use super::Dashboard;
use crate::dashboard::events::DashboardEvent;
use anyhow::Result;
use crossterm::event::KeyEvent;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table},
Frame,
};
use std::collections::HashMap;
use tokio::sync::mpsc;
/// Backtesting Dashboard for strategy testing and historical analysis
pub struct BacktestingDashboard {
/// Event sender for dashboard communications
_event_sender: mpsc::Sender<DashboardEvent>,
/// Active backtest status
active_backtests: Vec<BacktestEntry>,
/// Historical backtest results
historical_results: Vec<BacktestResult>,
/// Selected backtest in the list
selected_backtest: ListState,
/// Current view mode
view_mode: BacktestViewMode,
/// Performance metrics cache
#[allow(dead_code)]
metrics_cache: HashMap<String, PerformanceMetrics>,
/// Redraw flag
needs_redraw: bool,
}
/// View modes for the backtesting dashboard
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BacktestViewMode {
/// Show active running backtests
ActiveBacktests,
/// Show historical backtest results
HistoricalResults,
/// Show detailed performance analysis
PerformanceAnalysis,
/// Show strategy configuration
StrategyConfig,
}
/// Active backtest entry
#[derive(Debug, Clone)]
pub struct BacktestEntry {
/// Backtest ID
pub id: String,
/// Strategy name
pub strategy: String,
/// Symbols being tested
pub symbols: Vec<String>,
/// Progress percentage
pub progress: f64,
/// Current status
pub status: String,
/// Start time
pub started_at: String,
/// Estimated completion time
pub eta: Option<String>,
/// Current `PnL`
pub current_pnl: f64,
/// Trade count
pub trade_count: u64,
}
/// Historical backtest result
#[derive(Debug, Clone)]
pub struct BacktestResult {
/// Backtest ID
pub id: String,
/// Strategy name
pub strategy: String,
/// Symbols tested
pub symbols: Vec<String>,
/// Test period
pub period: String,
/// Final return
pub total_return: f64,
/// Sharpe ratio
pub sharpe_ratio: f64,
/// Maximum drawdown
pub max_drawdown: f64,
/// Win rate
pub win_rate: f64,
/// Total trades
pub total_trades: u64,
/// Completion date
pub completed_at: String,
}
/// Performance metrics for detailed analysis
#[derive(Debug, Clone)]
pub struct PerformanceMetrics {
/// Daily returns
pub daily_returns: Vec<f64>,
/// Cumulative returns
pub cumulative_returns: Vec<f64>,
/// Rolling Sharpe ratio
pub rolling_sharpe: Vec<f64>,
/// Drawdown series
pub drawdown_series: Vec<f64>,
/// Trade analysis
pub trade_metrics: TradeMetrics,
}
/// Trade execution metrics
#[derive(Debug, Clone)]
pub struct TradeMetrics {
/// Average trade duration (hours)
pub avg_duration: f64,
/// Average win amount
pub avg_win: f64,
/// Average loss amount
pub avg_loss: f64,
/// Profit factor
pub profit_factor: f64,
/// Maximum consecutive wins
pub max_consecutive_wins: u32,
/// Maximum consecutive losses
pub max_consecutive_losses: u32,
}
impl BacktestingDashboard {
/// Create a new backtesting dashboard
pub fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
let mut dashboard = Self {
_event_sender,
active_backtests: Vec::new(),
historical_results: Vec::new(),
selected_backtest: ListState::default(),
view_mode: BacktestViewMode::ActiveBacktests,
metrics_cache: HashMap::new(),
needs_redraw: true,
};
// Initialize with sample data
dashboard.load_sample_data();
dashboard
}
/// Load sample data for demonstration
fn load_sample_data(&mut self) {
// Sample active backtests
self.active_backtests = vec![
BacktestEntry {
id: "bt_001".to_owned(),
strategy: "MeanReversion_v2.1".to_owned(),
symbols: vec!["SPY".to_owned(), "QQQ".to_owned()],
progress: 73.5,
status: "Running".to_owned(),
started_at: "2025-01-23 14:30:15".to_owned(),
eta: Some("2025-01-23 16:45:00".to_owned()),
current_pnl: 12_450.75,
trade_count: 127,
},
BacktestEntry {
id: "bt_002".to_owned(),
strategy: "Momentum_ML_v1.3".to_owned(),
symbols: vec!["AAPL".to_owned(), "MSFT".to_owned(), "GOOGL".to_owned()],
progress: 28.2,
status: "Running".to_owned(),
started_at: "2025-01-23 15:15:30".to_owned(),
eta: Some("2025-01-23 18:20:00".to_owned()),
current_pnl: -2_100.25,
trade_count: 43,
},
];
// Sample historical results
self.historical_results = vec![
BacktestResult {
id: "bt_hist_001".to_owned(),
strategy: "MeanReversion_v2.0".to_owned(),
symbols: vec!["SPY".to_owned(), "QQQ".to_owned(), "IWM".to_owned()],
period: "2024-01-01 to 2024-12-31".to_owned(),
total_return: 18.75,
sharpe_ratio: 1.42,
max_drawdown: -8.3,
win_rate: 64.2,
total_trades: 284,
completed_at: "2025-01-22 18:45:12".to_owned(),
},
BacktestResult {
id: "bt_hist_002".to_owned(),
strategy: "Arbitrage_v3.1".to_owned(),
symbols: vec!["AAPL".to_owned(), "MSFT".to_owned()],
period: "2024-06-01 to 2024-12-31".to_owned(),
total_return: 12.34,
sharpe_ratio: 2.18,
max_drawdown: -3.7,
win_rate: 71.8,
total_trades: 156,
completed_at: "2025-01-21 22:15:45".to_owned(),
},
BacktestResult {
id: "bt_hist_003".to_owned(),
strategy: "Momentum_ML_v1.2".to_owned(),
symbols: vec!["QQQ".to_owned(), "XLK".to_owned(), "TQQQ".to_owned()],
period: "2024-03-01 to 2024-09-30".to_owned(),
total_return: 24.67,
sharpe_ratio: 1.89,
max_drawdown: -12.1,
win_rate: 58.9,
total_trades: 412,
completed_at: "2025-01-20 16:30:22".to_owned(),
},
];
// Select first item by default
self.selected_backtest.select(Some(0));
}
/// Render active backtests view
fn render_active_backtests(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)].as_ref())
.split(area);
// Header with summary
let summary = format!(
"Active Backtests: {} | Total Progress: {:.1}%",
self.active_backtests.len(),
self.active_backtests
.iter()
.map(|bt| bt.progress)
.sum::<f64>()
/ self.active_backtests.len() as f64
);
let header = Paragraph::new(summary).block(
Block::default()
.borders(Borders::ALL)
.title("Active Backtests Overview")
.style(Style::default().fg(Color::Green)),
);
frame.render_widget(header, chunks[0]);
// Active backtests list with progress bars
let items: Vec<ListItem> = self
.active_backtests
.iter()
.map(|bt| {
let pnl_color = if bt.current_pnl >= 0.0 {
Color::Green
} else {
Color::Red
};
let content = format!(
"{} | {} | {:.1}% | PnL: ${:.2} | Trades: {}",
bt.strategy,
bt.symbols.join(","),
bt.progress,
bt.current_pnl,
bt.trade_count
);
ListItem::new(content).style(Style::default().fg(pnl_color))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Running Backtests (\u{2191}\u{2193} to navigate, Enter for details)")
.style(Style::default().fg(Color::White)),
)
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25ba} ");
frame.render_stateful_widget(list, chunks[1], &mut self.selected_backtest);
Ok(())
}
/// Render historical results view
fn render_historical_results(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)].as_ref())
.split(area);
// Summary stats
let avg_return = self
.historical_results
.iter()
.map(|r| r.total_return)
.sum::<f64>()
/ self.historical_results.len() as f64;
let avg_sharpe = self
.historical_results
.iter()
.map(|r| r.sharpe_ratio)
.sum::<f64>()
/ self.historical_results.len() as f64;
let summary = format!(
"Historical Results: {} | Avg Return: {:.2}% | Avg Sharpe: {:.2}",
self.historical_results.len(),
avg_return,
avg_sharpe
);
let header = Paragraph::new(summary).block(
Block::default()
.borders(Borders::ALL)
.title("Historical Performance Summary")
.style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(header, chunks[0]);
// Results table
let headers = [
"Strategy", "Period", "Return%", "Sharpe", "MaxDD%", "WinRate%", "Trades",
];
let header_cells = headers
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
let header_row = Row::new(header_cells).style(Style::default().bg(Color::DarkGray));
let rows: Vec<Row> = self
.historical_results
.iter()
.map(|result| {
let return_color = if result.total_return >= 0.0 {
Color::Green
} else {
Color::Red
};
Row::new(vec![
Cell::from(result.strategy.as_str()),
Cell::from(result.period.as_str()),
Cell::from(format!("{:.2}", result.total_return))
.style(Style::default().fg(return_color)),
Cell::from(format!("{:.2}", result.sharpe_ratio)),
Cell::from(format!("{:.1}", result.max_drawdown))
.style(Style::default().fg(Color::Red)),
Cell::from(format!("{:.1}", result.win_rate)),
Cell::from(format!("{}", result.total_trades)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(18), // Strategy
Constraint::Length(22), // Period
Constraint::Length(8), // Return%
Constraint::Length(7), // Sharpe
Constraint::Length(8), // MaxDD%
Constraint::Length(9), // WinRate%
Constraint::Length(7), // Trades
],
)
.header(header_row)
.block(
Block::default()
.borders(Borders::ALL)
.title("Historical Backtest Results")
.style(Style::default().fg(Color::White)),
)
.column_spacing(1);
frame.render_widget(table, chunks[1]);
Ok(())
}
/// Switch to next view mode
fn next_view_mode(&mut self) {
self.view_mode = match self.view_mode {
BacktestViewMode::ActiveBacktests => BacktestViewMode::HistoricalResults,
BacktestViewMode::HistoricalResults => BacktestViewMode::PerformanceAnalysis,
BacktestViewMode::PerformanceAnalysis => BacktestViewMode::StrategyConfig,
BacktestViewMode::StrategyConfig => BacktestViewMode::ActiveBacktests,
};
self.needs_redraw = true;
}
/// Switch to previous view mode
fn previous_view_mode(&mut self) {
self.view_mode = match self.view_mode {
BacktestViewMode::ActiveBacktests => BacktestViewMode::StrategyConfig,
BacktestViewMode::HistoricalResults => BacktestViewMode::ActiveBacktests,
BacktestViewMode::PerformanceAnalysis => BacktestViewMode::HistoricalResults,
BacktestViewMode::StrategyConfig => BacktestViewMode::PerformanceAnalysis,
};
self.needs_redraw = true;
}
}
impl Dashboard for BacktestingDashboard {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
// Create main layout with tabs
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)].as_ref())
.split(area);
// Render view mode tabs
let tab_title = match self.view_mode {
BacktestViewMode::ActiveBacktests => "Active Backtests [Tab: Historical]",
BacktestViewMode::HistoricalResults => "Historical Results [Tab: Performance]",
BacktestViewMode::PerformanceAnalysis => "Performance Analysis [Tab: Strategy Config]",
BacktestViewMode::StrategyConfig => "Strategy Configuration [Tab: Active]",
};
let tab_block = Block::default()
.borders(Borders::ALL)
.title(format!("Backtesting Dashboard - {}", tab_title))
.style(Style::default().fg(Color::Magenta));
frame.render_widget(tab_block, chunks[0]);
// Render current view
match self.view_mode {
BacktestViewMode::ActiveBacktests => self.render_active_backtests(frame, chunks[1])?,
BacktestViewMode::HistoricalResults => {
self.render_historical_results(frame, chunks[1])?
},
BacktestViewMode::PerformanceAnalysis => {
// Placeholder for performance analysis view
let content = Paragraph::new(
"Performance Analysis View\n\n\
\u{2022} Cumulative returns chart\n\
\u{2022} Rolling Sharpe ratio\n\
\u{2022} Drawdown analysis\n\
\u{2022} Trade distribution metrics\n\
\u{2022} Risk-adjusted returns\n\n\
[Implementation in progress...]",
)
.block(
Block::default()
.borders(Borders::ALL)
.title("Performance Analysis")
.style(Style::default().fg(Color::Green)),
);
frame.render_widget(content, chunks[1]);
},
BacktestViewMode::StrategyConfig => {
// Placeholder for strategy configuration view
let content = Paragraph::new(
"Strategy Configuration View\n\n\
\u{2022} Parameter settings\n\
\u{2022} Optimization ranges\n\
\u{2022} Risk constraints\n\
\u{2022} Market data settings\n\
\u{2022} Execution parameters\n\n\
[Implementation in progress...]",
)
.block(
Block::default()
.borders(Borders::ALL)
.title("Strategy Configuration")
.style(Style::default().fg(Color::Yellow)),
);
frame.render_widget(content, chunks[1]);
},
}
self.needs_redraw = false;
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Tab => {
self.next_view_mode();
Ok(None)
},
KeyCode::BackTab => {
self.previous_view_mode();
Ok(None)
},
KeyCode::Up => {
if let Some(selected) = self.selected_backtest.selected() {
let max_items = match self.view_mode {
BacktestViewMode::ActiveBacktests => self.active_backtests.len(),
BacktestViewMode::HistoricalResults => self.historical_results.len(),
_ => 0,
};
if max_items > 0 {
let next = if selected > 0 {
selected - 1
} else {
max_items - 1
};
self.selected_backtest.select(Some(next));
self.needs_redraw = true;
}
}
Ok(None)
},
KeyCode::Down => {
let max_items = match self.view_mode {
BacktestViewMode::ActiveBacktests => self.active_backtests.len(),
BacktestViewMode::HistoricalResults => self.historical_results.len(),
_ => 0,
};
if max_items > 0 {
let selected = self.selected_backtest.selected().unwrap_or(0);
let next = if selected >= max_items - 1 {
0
} else {
selected + 1
};
self.selected_backtest.select(Some(next));
self.needs_redraw = true;
}
Ok(None)
},
KeyCode::Enter => {
// Handle selection - would show details or start actions
self.needs_redraw = true;
Ok(None)
},
KeyCode::Char('r') => {
// Refresh data
self.load_sample_data();
self.needs_redraw = true;
Ok(None)
},
_ => Ok(None),
}
}
fn update(&mut self, _event: DashboardEvent) -> Result<()> {
// Handle backtest-related events
self.needs_redraw = true;
Ok(())
}
fn title(&self) -> &str {
"Backtesting"
}
fn shortcut_key(&self) -> char {
'b'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}

View File

@@ -1,16 +0,0 @@
//! Configuration Dashboard Implementation
//!
//! Re-exports the comprehensive `ConfigurationDashboard` from dashboards/configuration.rs
// REMOVED: All pub use statements eliminated per cleanup requirements
// Use direct import: crate::dashboards::configuration::ConfigurationDashboard
// Legacy compatibility - keeping the same interface
use super::Dashboard;
use crate::dashboard::events::DashboardEvent;
use tokio::sync::mpsc;
/// Create a new configuration dashboard
pub fn create_config_dashboard(_event_sender: mpsc::Sender<DashboardEvent>) -> Box<dyn Dashboard> {
Box::new(crate::dashboards::configuration::ConfigurationDashboard::new(_event_sender))
}

View File

@@ -1,227 +0,0 @@
//! Dashboard Event System
//!
//! Defines all events that can be exchanged between dashboards, gRPC clients,
//! and the main application loop.
use crate::dashboard::DashboardType;
use serde::{Deserialize, Serialize};
// Use canonical types from common crate - TLI is a pure client
use common::{Order as OrderRequest, OrderEvent, OrderSide};
/// Main event type for dashboard communication
#[derive(Debug, Clone)]
pub enum DashboardEvent {
// Navigation events
SwitchDashboard(DashboardType),
Exit,
// Real-time data updates
MarketDataUpdate(MarketDataDisplayEvent),
PositionUpdate(PositionEvent),
OrderUpdate(OrderEvent),
ExecutionUpdate(ExecutionEvent),
RiskMetricsUpdate(RiskMetricsEvent),
MLPredictionUpdate(MLPredictionEvent),
ConfigurationUpdate(ConfigurationEvent),
VaultStatusUpdate(crate::dashboard::vault_status::VaultStats),
// User action events
PlaceOrder(OrderRequest),
CancelOrder(String), // Order ID
UpdateConfiguration(ConfigUpdate),
TriggerEmergencyStop,
RefreshData,
RefreshConfig,
ShowHelp(String),
// Configuration events
ConfigChanged {
category: String,
key: String,
},
ConfigReloaded,
ConfigUpdateRequest(ConfigUpdateRequest),
ConfigUpdate {
category_id: i32,
settings: Vec<crate::proto::config::ConfigSetting>,
},
ConfigSearchResults {
results: Vec<crate::proto::config::ConfigSetting>,
},
// System events
ConnectionStatus(ConnectionEvent),
Error(String),
SystemStatus(SystemStatusEvent),
}
// Market Data Display Events (TLI-specific UI representation)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MarketDataDisplayEvent {
pub symbol: String,
pub price: f64,
pub volume: u64,
pub timestamp: i64,
pub bid: Option<f64>,
pub ask: Option<f64>,
pub change: Option<f64>,
pub change_percent: Option<f64>,
}
// Position Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionEvent {
pub symbol: String,
pub quantity: f64,
pub avg_price: f64,
pub current_price: f64,
pub unrealized_pnl: f64,
pub realized_pnl: f64,
pub timestamp: i64,
}
// Order Events - using canonical OrderEvent from trading_engine
// Execution Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionEvent {
pub execution_id: String,
pub order_id: String,
pub symbol: String,
pub side: OrderSide,
pub quantity: f64,
pub price: f64,
pub timestamp: i64,
pub commission: Option<f64>,
}
// Risk Metrics Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskMetricsEvent {
pub portfolio_value: f64,
pub daily_pnl: f64,
pub total_pnl: f64,
pub var_1d: f64,
pub var_5d: f64,
pub max_drawdown: f64,
pub current_drawdown: f64,
pub risk_score: f64,
pub timestamp: i64,
}
// ML Prediction Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MLPredictionEvent {
pub model_name: String,
pub symbol: String,
pub prediction: PredictionType,
pub confidence: f64,
pub signal_strength: f64,
pub features: Vec<f64>,
pub timestamp: i64,
}
// Configuration Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigurationEvent {
pub category: String,
pub key: String,
pub old_value: Option<String>,
pub new_value: String,
pub timestamp: i64,
pub changed_by: String,
}
// OrderRequest is now imported at the top
// Configuration Update
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigUpdate {
pub category: String,
pub key: String,
pub value: serde_json::Value,
pub changed_by: String,
}
// Configuration Update Request for gRPC communication - PURE CLIENT ARCHITECTURE
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigUpdateRequest {
pub category: String,
pub key: String,
pub value: serde_json::Value,
pub reason: String,
}
// Connection Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionEvent {
pub service_name: String,
pub status: ConnectionStatus,
pub endpoint: String,
pub latency_ms: Option<u64>,
pub last_seen: i64,
}
// System Status Events
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemStatusEvent {
pub trading_enabled: bool,
pub risk_controls_active: bool,
pub ml_models_online: u32,
pub total_ml_models: u32,
pub active_positions: u32,
pub pending_orders: u32,
pub timestamp: i64,
}
// OrderSide is now imported at the top
// OrderType and OrderStatus now imported from canonical source via common::types
// REMOVED: TimeInForce duplicate - use common::TimeInForce
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum PredictionType {
Buy,
Sell,
Hold,
StrongBuy,
StrongSell,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ConnectionStatus {
Connected,
Disconnected,
Connecting,
Error,
}
// OrderSide Display implementation now provided by canonical source
// OrderType Display implementation now provided by canonical source
// OrderStatus Display implementation now provided by canonical source
impl std::fmt::Display for PredictionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PredictionType::Buy => write!(f, "BUY"),
PredictionType::Sell => write!(f, "SELL"),
PredictionType::Hold => write!(f, "HOLD"),
PredictionType::StrongBuy => write!(f, "STRONG_BUY"),
PredictionType::StrongSell => write!(f, "STRONG_SELL"),
}
}
}
impl std::fmt::Display for ConnectionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectionStatus::Connected => write!(f, "\u{25cf}\u{25cf}\u{25cf}"),
ConnectionStatus::Disconnected => write!(f, "\u{25cb}\u{25cb}\u{25cb}"),
ConnectionStatus::Connecting => write!(f, "\u{25cf}\u{25cb}\u{25cb}"),
ConnectionStatus::Error => write!(f, "\u{2717}\u{2717}\u{2717}"),
}
}
}

View File

@@ -1,57 +0,0 @@
//! Layout management for TLI dashboards
use ratatui::prelude::*;
/// Layout manager for consistent UI layouts
pub struct LayoutManager {
/// Current layout configuration
pub layout_type: LayoutType,
}
#[derive(Debug, Clone, Copy)]
pub enum LayoutType {
Standard,
Compact,
Extended,
}
impl Default for LayoutManager {
fn default() -> Self {
Self::new()
}
}
impl LayoutManager {
pub const fn new() -> Self {
Self {
layout_type: LayoutType::Standard,
}
}
pub fn create_layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
// Create header, content, sidebar, footer areas
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(10), // Content
Constraint::Length(3), // Footer
])
.split(area);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(80), // Main content
Constraint::Length(25), // Sidebar
])
.split(chunks[1]);
(
chunks[0_usize],
middle_chunks[0_usize],
middle_chunks[1_usize],
chunks[2_usize],
)
}
}

View File

@@ -1,604 +0,0 @@
//! ML Training Dashboard Implementation
//!
//! Comprehensive ML training management dashboard with:
//! - Real-time training progress monitoring
//! - Model performance metrics visualization
//! - Training data quality indicators
//! - Resource utilization tracking (GPU/CPU)
//! - Training job lifecycle management
use super::Dashboard;
use crate::client::ml_training_client::{
MLTrainingClient, ResourceMonitoringEvent, TrainingJobContext, TrainingProgressEvent,
};
use crate::dashboard::events::DashboardEvent;
use crate::proto::ml::{TrainingJob, TrainingMetrics, TrainingStatus};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::time::Instant;
/// Training job display information
#[derive(Debug, Clone)]
pub struct TrainingJobDisplay {
pub job: TrainingJob,
pub context: TrainingJobContext,
pub last_update: Instant,
pub progress_history: Vec<(Instant, f64)>, // Time, progress percentage
pub metrics_history: Vec<(Instant, TrainingMetrics)>, // Time, metrics
pub is_selected: bool,
}
/// Resource utilization display
#[derive(Debug, Clone)]
pub struct ResourceDisplay {
pub gpu_utilization: f64,
pub gpu_memory_used: f64,
pub cpu_utilization: f64,
pub memory_used: f64,
pub available_gpus: i32,
pub total_gpus: i32,
pub last_update: Instant,
pub history: Vec<(Instant, f64, f64)>, // Time, GPU util, CPU util
}
/// Dashboard state for ML training management
#[derive(Debug, Clone)]
pub enum MLDashboardState {
JobList, // Main job list view
JobDetail, // Detailed view of selected job
StartJob, // Job creation form
ResourceView, // Resource monitoring view
}
/// ML Training Dashboard with comprehensive management features
pub struct MLDashboard {
_event_sender: mpsc::Sender<DashboardEvent>,
needs_redraw: bool,
state: MLDashboardState,
// Training job management
training_jobs: HashMap<String, TrainingJobDisplay>,
selected_job_id: Option<String>,
job_list_scroll: usize,
// Resource monitoring
resource_display: Option<ResourceDisplay>,
// ML Training client integration
ml_client: Option<Arc<MLTrainingClient>>,
#[allow(dead_code)]
progress_receivers: HashMap<String, mpsc::Receiver<TrainingProgressEvent>>,
#[allow(dead_code)]
resource_receiver: Option<mpsc::Receiver<ResourceMonitoringEvent>>,
// UI state
#[allow(dead_code)]
show_logs: bool,
auto_refresh: bool,
refresh_interval: std::time::Duration,
last_refresh: Instant,
// Form state for job creation
form_model_name: String,
form_dataset_id: String,
form_learning_rate: String,
form_batch_size: String,
form_epochs: String,
form_field_index: usize,
}
impl MLDashboard {
pub fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
Self {
_event_sender,
needs_redraw: true,
state: MLDashboardState::JobList,
training_jobs: HashMap::new(),
selected_job_id: None,
job_list_scroll: 0_usize,
resource_display: None,
ml_client: None,
progress_receivers: HashMap::new(),
resource_receiver: None,
show_logs: false,
auto_refresh: true,
refresh_interval: std::time::Duration::from_secs(5),
last_refresh: Instant::now(),
form_model_name: String::new(),
form_dataset_id: String::new(),
form_learning_rate: "0.001".to_owned(),
form_batch_size: "32".to_owned(),
form_epochs: "100".to_owned(),
form_field_index: 0_usize,
}
}
/// Set the ML training client for dashboard operations
pub fn set_ml_client(&mut self, client: Arc<MLTrainingClient>) {
self.ml_client = Some(client);
self.needs_redraw = true;
}
/// Add or update a training job
pub fn update_training_job(&mut self, job: TrainingJob, context: TrainingJobContext) {
let job_id = job.job_id.clone();
if let Some(display) = self.training_jobs.get_mut(&job_id) {
// Update existing job
display.job = job;
display.context = context;
display.last_update = Instant::now();
// Add progress point to history
display
.progress_history
.push((Instant::now(), display.job.progress_percentage));
// Keep only last 100 data points
if display.progress_history.len() > 100 {
display.progress_history.remove(0);
}
} else {
// New job
let display = TrainingJobDisplay {
job: job.clone(),
context,
last_update: Instant::now(),
progress_history: vec![(Instant::now(), job.progress_percentage)],
metrics_history: Vec::new(),
is_selected: self.selected_job_id.as_ref() == Some(&job_id),
};
self.training_jobs.insert(job_id, display);
}
self.needs_redraw = true;
}
/// Update resource utilization
pub fn update_resource_utilization(
&mut self,
gpu_util: f64,
gpu_memory: f64,
cpu_util: f64,
memory: f64,
available_gpus: i32,
total_gpus: i32,
) {
if let Some(resource) = &mut self.resource_display {
resource.gpu_utilization = gpu_util;
resource.gpu_memory_used = gpu_memory;
resource.cpu_utilization = cpu_util;
resource.memory_used = memory;
resource.available_gpus = available_gpus;
resource.total_gpus = total_gpus;
resource.last_update = Instant::now();
// Add to history
resource.history.push((Instant::now(), gpu_util, cpu_util));
if resource.history.len() > 60 {
// Keep 1 minute of data
resource.history.remove(0);
}
} else {
self.resource_display = Some(ResourceDisplay {
gpu_utilization: gpu_util,
gpu_memory_used: gpu_memory,
cpu_utilization: cpu_util,
memory_used: memory,
available_gpus,
total_gpus,
last_update: Instant::now(),
history: vec![(Instant::now(), gpu_util, cpu_util)],
});
}
self.needs_redraw = true;
}
/// Render the job list view
fn render_job_list(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(10), // Job table
Constraint::Length(4), // Resource summary
])
.split(area);
// Header with controls
let header = Paragraph::new(
"ML Training Dashboard | [s] Start Job | [Enter] Job Details | [r] Resources | [q] Quit"
)
.block(Block::default().borders(Borders::ALL).title("Training Jobs"))
.style(Style::default().fg(Color::Cyan));
frame.render_widget(header, chunks[0_usize]);
// Job table
let jobs: Vec<_> = self.training_jobs.values().collect();
let rows: Vec<Row> = jobs
.iter()
.map(|job_display| {
let status_style = match job_display.job.status() {
TrainingStatus::Running => Style::default().fg(Color::Green),
TrainingStatus::Completed => Style::default().fg(Color::Blue),
TrainingStatus::Failed => Style::default().fg(Color::Red),
TrainingStatus::Queued => Style::default().fg(Color::Yellow),
_ => Style::default().fg(Color::Gray),
};
let progress_bar = format!("{:>6.1}%", job_display.job.progress_percentage);
let status_text = format!("{:?}", job_display.job.status());
let model_name = job_display.job.model_name.clone();
let job_id_short = job_display.job.job_id.chars().take(8).collect::<String>();
Row::new(vec![
Cell::from(job_id_short),
Cell::from(model_name),
Cell::from(status_text).style(status_style),
Cell::from(progress_bar),
Cell::from(format!(
"{:.3}",
job_display
.job
.current_metrics
.as_ref()
.map(|m| m.loss)
.unwrap_or(0.0)
)),
Cell::from(format!(
"{:.1}%",
job_display
.job
.current_metrics
.as_ref()
.map(|m| m.accuracy * 100.0)
.unwrap_or(0.0)
)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8), // Job ID
Constraint::Length(15), // Model
Constraint::Length(12), // Status
Constraint::Length(8), // Progress
Constraint::Length(8), // Loss
Constraint::Length(8), // Accuracy
],
)
.header(
Row::new(vec![
"Job ID", "Model", "Status", "Progress", "Loss", "Accuracy",
])
.style(Style::default().fg(Color::Yellow)),
)
.block(
Block::default()
.borders(Borders::ALL)
.title("Active Training Jobs"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(table, chunks[1_usize], &mut TableState::default());
// Resource summary
if let Some(resource) = &self.resource_display {
let resource_info = format!(
"GPU: {:.1}% ({}/{} available) | CPU: {:.1}% | Memory: {:.1}%",
resource.gpu_utilization * 100.0,
resource.available_gpus,
resource.total_gpus,
resource.cpu_utilization * 100.0,
resource.memory_used * 100.0
);
let resource_widget = Paragraph::new(resource_info)
.block(
Block::default()
.borders(Borders::ALL)
.title("Resource Utilization"),
)
.style(Style::default().fg(Color::Green));
frame.render_widget(resource_widget, chunks[2_usize]);
}
Ok(())
}
/// Render the job creation form
fn render_start_job_form(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title
Constraint::Length(3), // Model name
Constraint::Length(3), // Dataset ID
Constraint::Length(3), // Learning rate
Constraint::Length(3), // Batch size
Constraint::Length(3), // Epochs
Constraint::Length(3), // Actions
Constraint::Min(1), // Spacer
])
.split(area);
// Title
let title = Paragraph::new("Start New Training Job")
.block(
Block::default()
.borders(Borders::ALL)
.title("Create Training Job"),
)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(title, chunks[0_usize]);
// Form fields
let fields = [
("Model Name", &self.form_model_name),
("Dataset ID", &self.form_dataset_id),
("Learning Rate", &self.form_learning_rate),
("Batch Size", &self.form_batch_size),
("Epochs", &self.form_epochs),
];
for (i, (label, value)) in fields.into_iter().enumerate() {
let style = if i == self.form_field_index {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let field = Paragraph::new(format!("{}: {}", label, value))
.block(Block::default().borders(Borders::ALL))
.style(style);
frame.render_widget(field, chunks[i + 1_usize]);
}
// Actions
let actions = Paragraph::new("[Enter] Start Job | [Esc] Cancel | [Tab] Next Field")
.block(Block::default().borders(Borders::ALL).title("Actions"))
.style(Style::default().fg(Color::Green));
frame.render_widget(actions, chunks[6_usize]);
Ok(())
}
/// Render resource monitoring view
fn render_resource_view(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Length(6), // GPU info
Constraint::Length(6), // CPU/Memory info
Constraint::Min(8), // Usage chart
])
.split(area);
// Header
let header = Paragraph::new("Resource Monitoring | [Esc] Back to Jobs")
.block(
Block::default()
.borders(Borders::ALL)
.title("System Resources"),
)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(header, chunks[0_usize]);
if let Some(resource) = &self.resource_display {
// GPU information
let gpu_info = format!(
"GPU Utilization: {:.1}%\nGPU Memory: {:.1}%\nAvailable GPUs: {}/{}\nGPU Type: V100/A100",
resource.gpu_utilization * 100.0,
resource.gpu_memory_used * 100.0,
resource.available_gpus,
resource.total_gpus
);
let gpu_widget = Paragraph::new(gpu_info)
.block(Block::default().borders(Borders::ALL).title("GPU Status"))
.style(Style::default().fg(Color::Green));
frame.render_widget(gpu_widget, chunks[1_usize]);
// CPU/Memory information
let cpu_info = format!(
"CPU Utilization: {:.1}%\nMemory Usage: {:.1}%\nActive Training Jobs: {}\nLast Update: {:?} ago",
resource.cpu_utilization * 100.0,
resource.memory_used * 100.0,
self.training_jobs.len(),
resource.last_update.elapsed()
);
let cpu_widget = Paragraph::new(cpu_info)
.block(
Block::default()
.borders(Borders::ALL)
.title("CPU/Memory Status"),
)
.style(Style::default().fg(Color::Blue));
frame.render_widget(cpu_widget, chunks[2_usize]);
// Usage chart (simplified representation)
let chart_data = if resource.history.len() > 1 {
resource
.history
.iter()
.enumerate()
.map(|(i, (_, gpu, cpu))| {
format!("{:2}: GPU {:3.0}% CPU {:3.0}%", i, gpu * 100.0, cpu * 100.0)
})
.collect::<Vec<_>>()
.join("\n")
} else {
"Collecting data...".to_owned()
};
let chart_widget = Paragraph::new(chart_data)
.block(
Block::default()
.borders(Borders::ALL)
.title("Usage History"),
)
.wrap(Wrap { trim: true });
frame.render_widget(chart_widget, chunks[3_usize]);
} else {
let no_data = Paragraph::new("No resource data available")
.block(
Block::default()
.borders(Borders::ALL)
.title("Resource Monitor"),
)
.style(Style::default().fg(Color::Red));
frame.render_widget(no_data, chunks[1_usize]);
}
Ok(())
}
}
impl Dashboard for MLDashboard {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
match self.state {
MLDashboardState::JobList => self.render_job_list(frame, area),
MLDashboardState::JobDetail => {
// TODO: Implement detailed job view
self.render_job_list(frame, area)
},
MLDashboardState::StartJob => self.render_start_job_form(frame, area),
MLDashboardState::ResourceView => self.render_resource_view(frame, area),
}
}
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
match self.state {
MLDashboardState::JobList => match key.code {
KeyCode::Char('s') => {
self.state = MLDashboardState::StartJob;
self.needs_redraw = true;
},
KeyCode::Char('r') => {
self.state = MLDashboardState::ResourceView;
self.needs_redraw = true;
},
KeyCode::Enter => {
self.state = MLDashboardState::JobDetail;
self.needs_redraw = true;
},
KeyCode::Up => {
self.job_list_scroll = self.job_list_scroll.saturating_sub(1);
self.needs_redraw = true;
},
KeyCode::Down => {
if self.job_list_scroll < self.training_jobs.len().saturating_sub(1) {
self.job_list_scroll += 1;
}
self.needs_redraw = true;
},
_ => {},
},
MLDashboardState::StartJob => {
match key.code {
KeyCode::Esc => {
self.state = MLDashboardState::JobList;
self.needs_redraw = true;
},
KeyCode::Tab => {
self.form_field_index = (self.form_field_index + 1) % 5;
self.needs_redraw = true;
},
KeyCode::Enter => {
// TODO: Start training job
self.state = MLDashboardState::JobList;
self.needs_redraw = true;
},
KeyCode::Char(c) => {
match self.form_field_index {
0 => self.form_model_name.push(c),
1 => self.form_dataset_id.push(c),
2 => self.form_learning_rate.push(c),
3 => self.form_batch_size.push(c),
4 => self.form_epochs.push(c),
_ => {},
}
self.needs_redraw = true;
},
KeyCode::Backspace => {
match self.form_field_index {
0 => {
self.form_model_name.pop();
},
1 => {
self.form_dataset_id.pop();
},
2 => {
self.form_learning_rate.pop();
},
3 => {
self.form_batch_size.pop();
},
4 => {
self.form_epochs.pop();
},
_ => {},
}
self.needs_redraw = true;
},
_ => {},
}
},
MLDashboardState::ResourceView | MLDashboardState::JobDetail => {
if key.code == KeyCode::Esc {
self.state = MLDashboardState::JobList;
self.needs_redraw = true;
}
},
}
Ok(None)
}
fn update(&mut self, _event: DashboardEvent) -> Result<()> {
// Auto-refresh logic
if self.auto_refresh && self.last_refresh.elapsed() > self.refresh_interval {
self.needs_redraw = true;
self.last_refresh = Instant::now();
}
Ok(())
}
fn title(&self) -> &str {
"ML Training"
}
fn shortcut_key(&self) -> char {
'm'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}

View File

@@ -1,322 +0,0 @@
//! Dashboard Framework for TLI Terminal Interface
//!
//! This module provides a comprehensive dashboard system for the Foxhunt HFT trading system.
//! It implements a multi-dashboard architecture with real-time data streaming and interactive
//! controls using Ratatui for terminal-based visualization.
//!
//! ## Architecture
//! - **`DashboardManager`**: Central coordinator for all dashboards
//! - **Dashboard Trait**: Common interface for all dashboard implementations
//! - **Real-time Updates**: Event-driven data streaming from gRPC services
//! - **Navigation**: Keyboard shortcuts for dashboard switching
//! - **Layout Management**: Consistent UI layout across all dashboards
use anyhow::Result;
use crossterm::event::KeyEvent;
use ratatui::prelude::*;
use std::collections::HashMap;
use tokio::sync::mpsc;
// Import from events module
use crate::dashboard::events::DashboardEvent;
use crate::dashboard::layout::LayoutManager;
pub mod backtesting;
pub mod config;
pub mod events;
pub mod layout;
pub mod ml;
pub mod performance;
pub mod risk;
pub mod trading;
pub mod vault_status;
// Import dashboard implementations after module declarations
use backtesting::BacktestingDashboard;
use config::create_config_dashboard;
use ml::MLDashboard;
use performance::PerformanceDashboard;
use risk::RiskDashboard;
use trading::TradingDashboard;
use vault_status::VaultStatusWidget;
// NO RE-EXPORTS: Import directly from submodules
// Use tli::dashboard::events::DashboardEvent instead
// Use tli::dashboard::layout::LayoutManager instead
/// Main dashboard manager that coordinates all dashboards
pub struct DashboardManager {
pub active_dashboard: DashboardType,
pub dashboards: HashMap<DashboardType, Box<dyn Dashboard>>,
pub layout_manager: LayoutManager,
pub event_receiver: mpsc::Receiver<DashboardEvent>,
pub _event_sender: mpsc::Sender<DashboardEvent>,
// Vault service removed - TLI is pure client, uses shared config crate
}
/// Available dashboard types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DashboardType {
Trading, // Live positions, orders, executions, market data
Risk, // VaR, drawdown, position limits, safety controls
ML, // Model predictions, signal strength, confidence
Performance, // PnL, Sharpe ratios, strategy performance
Config, // System configuration management
Backtesting, // Strategy testing, historical analysis, results
Vault, // Vault status, credentials, service discovery
}
impl DashboardType {
pub fn all() -> Vec<DashboardType> {
vec![
DashboardType::Trading,
DashboardType::Risk,
DashboardType::ML,
DashboardType::Performance,
DashboardType::Config,
DashboardType::Backtesting,
DashboardType::Vault,
]
}
pub const fn shortcut_key(&self) -> char {
match self {
DashboardType::Trading => 't',
DashboardType::Risk => 'r',
DashboardType::ML => 'm',
DashboardType::Performance => 'p',
DashboardType::Config => 'c',
DashboardType::Backtesting => 'b',
DashboardType::Vault => 'v',
}
}
pub const fn title(&self) -> &'static str {
match self {
DashboardType::Trading => "Trading",
DashboardType::Risk => "Risk",
DashboardType::ML => "ML",
DashboardType::Performance => "Performance",
DashboardType::Config => "Configuration",
DashboardType::Backtesting => "Backtesting",
DashboardType::Vault => "Vault Status",
}
}
}
/// Common interface for all dashboard implementations
pub trait Dashboard: Send + Sync {
/// Render the dashboard to the given frame area
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
/// Handle keyboard input and return optional dashboard events
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>>;
/// Update dashboard with new data/events
fn update(&mut self, event: DashboardEvent) -> Result<()>;
/// Get dashboard title for display
fn title(&self) -> &str;
/// Get keyboard shortcut for this dashboard
fn shortcut_key(&self) -> char;
/// Check if dashboard needs redraw
fn needs_redraw(&self) -> bool;
/// Mark dashboard as drawn
fn mark_drawn(&mut self);
}
impl DashboardManager {
pub fn new() -> (Self, mpsc::Sender<DashboardEvent>) {
let (_event_sender, event_receiver) = mpsc::channel(1000);
let mut dashboards: HashMap<DashboardType, Box<dyn Dashboard>> = HashMap::new();
// Initialize all dashboards
dashboards.insert(
DashboardType::Trading,
Box::new(TradingDashboard::new(_event_sender.clone())),
);
dashboards.insert(
DashboardType::Risk,
Box::new(RiskDashboard::new(_event_sender.clone())),
);
dashboards.insert(
DashboardType::ML,
Box::new(MLDashboard::new(_event_sender.clone())),
);
dashboards.insert(
DashboardType::Performance,
Box::new(PerformanceDashboard::new(_event_sender.clone())),
);
dashboards.insert(
DashboardType::Config,
create_config_dashboard(_event_sender.clone()),
);
dashboards.insert(
DashboardType::Backtesting,
Box::new(BacktestingDashboard::new(_event_sender.clone())),
);
dashboards.insert(
DashboardType::Vault,
Box::new(VaultStatusWidget::new(_event_sender.clone())),
);
let manager = Self {
active_dashboard: DashboardType::Trading,
dashboards,
layout_manager: LayoutManager::new(),
event_receiver,
_event_sender: _event_sender.clone(),
};
(manager, _event_sender)
}
pub fn render(&mut self, frame: &mut Frame) -> Result<()> {
let area = frame.area();
// Create main layout
let (header_area, content_area, sidebar_area, footer_area) =
self.layout_manager.create_layout(area);
// Render header with navigation tabs
self.render_header(frame, header_area)?;
// Render active dashboard
if let Some(dashboard) = self.dashboards.get_mut(&self.active_dashboard) {
dashboard.render(frame, content_area)?;
}
// Render sidebar with quick stats
self.render_sidebar(frame, sidebar_area)?;
// Render footer with help and status
self.render_footer(frame, footer_area)?;
Ok(())
}
pub fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
// Check for dashboard switching shortcuts first
for dashboard_type in DashboardType::all() {
if key.code == crossterm::event::KeyCode::Char(dashboard_type.shortcut_key()) {
self.active_dashboard = dashboard_type;
return Ok(Some(DashboardEvent::SwitchDashboard(dashboard_type)));
}
}
// Handle ESC for exit
if key.code == crossterm::event::KeyCode::Esc {
return Ok(Some(DashboardEvent::Exit));
}
// Pass input to active dashboard
if let Some(dashboard) = self.dashboards.get_mut(&self.active_dashboard) {
dashboard.handle_input(key)
} else {
Ok(None)
}
}
pub async fn handle_event(&mut self, event: DashboardEvent) -> Result<bool> {
match event {
DashboardEvent::SwitchDashboard(dashboard_type) => {
self.active_dashboard = dashboard_type;
Ok(false)
},
DashboardEvent::Exit => {
Ok(true) // Signal to exit
},
_ => {
// Forward event to all dashboards that might be interested
for dashboard in self.dashboards.values_mut() {
let _ = dashboard.update(event.clone());
}
Ok(false)
},
}
}
// Vault service functionality removed - TLI is pure client, uses shared config crate
/// All vault-related methods removed - TLI uses shared config crate instead
// Vault service functionality completely removed from TLI
// TLI is a pure client - no vault service management
fn render_header(&self, frame: &mut Frame, area: Rect) -> Result<()> {
let titles: Vec<String> = DashboardType::all()
.iter()
.map(|dt| {
let _prefix = if *dt == self.active_dashboard {
"\u{25cf}"
} else {
"\u{25cb}"
};
format!("[{}]{}", dt.shortcut_key().to_uppercase(), dt.title())
})
.collect();
let tabs = ratatui::widgets::Tabs::new(titles)
.block(
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.title("Foxhunt HFT Trading System - TLI Terminal"),
)
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.select(self.active_dashboard as usize);
frame.render_widget(tabs, area);
Ok(())
}
fn render_sidebar(&self, frame: &mut Frame, area: Rect) -> Result<()> {
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.title("Quick Stats");
// Get actual Vault status (placeholder - TLI uses shared config crate)
let vault_status = "\u{25cb}"; // Empty circle for not available - use config crate integration
let content = ratatui::widgets::Paragraph::new(
format!(
"Connection: \u{25cf}\u{25cf}\u{25cf}\nVault: {}\nLatency: 12ms\nOrders: 15\nPositions: 5\nPnL: +$2,500",
vault_status
),
)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(content, area);
Ok(())
}
fn render_footer(&self, frame: &mut Frame, area: Rect) -> Result<()> {
let help_text = format!(
"[{}] Dashboards | [ESC] Exit | Status: Connected",
DashboardType::all()
.iter()
.map(|dt| format!(
"[{}]{}",
dt.shortcut_key().to_uppercase(),
dt.title().chars().next().unwrap_or(' ')
))
.collect::<Vec<_>>()
.join(" ")
);
let footer = ratatui::widgets::Paragraph::new(help_text)
.block(ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::ALL))
.style(Style::default().fg(Color::Gray));
frame.render_widget(footer, area);
Ok(())
}
}

View File

@@ -1,597 +0,0 @@
//! # Enhanced Observability Dashboard
//!
//! Comprehensive observability dashboard for the Foxhunt HFT system featuring:
//! - OpenTelemetry/OTLP distributed tracing visualization
//! - P50/P95/P99 order acknowledgment latency histograms
//! - Real-time Parquet market data persistence monitoring
//! - System-wide metrics across all critical paths
use crate::error::TliResult;
// All types from common crate - TLI is a pure client
use common::{
get_order_ack_percentiles, LatencyPercentiles, MarketDataEvent,
MARKET_DATA_BUFFER, TELEMETRY_TRACER, ORDER_ACK_LATENCY,
HardwareTimestamp, LatencyStats, HftLatencyTracker
};
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span, Text},
widgets::{
Axis, BarChart, Block, Borders, Chart, Clear, Dataset, Gauge, List, ListItem,
Paragraph, Row, Sparkline, Table, Tabs,
},
Frame,
};
use std::collections::HashMap;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
/// Enhanced observability dashboard state
#[derive(Debug)]
pub struct ObservabilityDashboard {
pub selected_tab: usize,
pub latency_history: Vec<f64>,
pub throughput_history: Vec<u64>,
pub parquet_buffer_stats: BufferStats,
pub order_ack_stats: HashMap<String, LatencyPercentiles>,
pub telemetry_spans: Vec<SpanInfo>,
pub system_metrics: SystemMetrics,
pub update_counter: u64,
}
#[derive(Debug, Clone)]
pub struct BufferStats {
pub buffered_events: usize,
pub buffer_capacity: usize,
pub utilization_percent: f64,
pub events_per_second: f64,
pub last_flush_ago: u64, // seconds
}
#[derive(Debug, Clone)]
pub struct SpanInfo {
pub operation: String,
pub venue: String,
pub duration_us: f64,
pub timestamp: u64,
pub trace_id: String,
pub span_id: String,
}
#[derive(Debug, Clone)]
pub struct SystemMetrics {
pub cpu_usage: f64,
pub memory_usage: f64,
pub network_rx: u64,
pub network_tx: u64,
pub disk_io: u64,
pub active_connections: u32,
}
impl Default for ObservabilityDashboard {
fn default() -> Self {
Self {
selected_tab: 0,
latency_history: Vec::with_capacity(100),
throughput_history: Vec::with_capacity(100),
parquet_buffer_stats: BufferStats {
buffered_events: 0,
buffer_capacity: 10000,
utilization_percent: 0.0,
events_per_second: 0.0,
last_flush_ago: 0,
},
order_ack_stats: HashMap::new(),
telemetry_spans: Vec::new(),
system_metrics: SystemMetrics {
cpu_usage: 0.0,
memory_usage: 0.0,
network_rx: 0,
network_tx: 0,
disk_io: 0,
active_connections: 0,
},
update_counter: 0,
}
}
}
impl ObservabilityDashboard {
pub fn new() -> Self {
Self::default()
}
/// Update dashboard with latest metrics
pub async fn update(&mut self) -> TliResult<()> {
self.update_counter += 1;
// Update order acknowledgment latency stats
self.update_order_ack_stats().await;
// Update Parquet buffer stats
self.update_parquet_buffer_stats().await;
// Update telemetry spans
self.update_telemetry_spans().await;
// Update system metrics
self.update_system_metrics().await;
// Update latency history (simulated for now)
if self.latency_history.len() >= 100 {
self.latency_history.remove(0);
}
self.latency_history.push(self.get_current_latency_us());
// Update throughput history
if self.throughput_history.len() >= 100 {
self.throughput_history.remove(0);
}
self.throughput_history.push(self.get_current_throughput());
Ok(())
}
/// Render the enhanced observability dashboard
pub fn render<B: Backend>(&mut self, frame: &mut Frame<B>, area: Rect) {
let tabs = vec!["Latency", "Throughput", "Parquet", "Telemetry", "System"];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Render tabs
let tabs_widget = Tabs::new(tabs)
.block(Block::default().borders(Borders::ALL).title("Observability Dashboard"))
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.select(self.selected_tab);
frame.render_widget(tabs_widget, chunks[0]);
// Render selected tab content
match self.selected_tab {
0 => self.render_latency_tab(frame, chunks[1]),
1 => self.render_throughput_tab(frame, chunks[1]),
2 => self.render_parquet_tab(frame, chunks[1]),
3 => self.render_telemetry_tab(frame, chunks[1]),
4 => self.render_system_tab(frame, chunks[1]),
_ => {}
}
}
/// Render latency analysis tab with P50/P95/P99 histograms
fn render_latency_tab<B: Backend>(&self, frame: &mut Frame<B>, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// Top section: Latency chart
let latency_chart = Chart::new(vec![
Dataset::default()
.name("Order Latency (μs)")
.marker(symbols::Marker::Braille)
.style(Style::default().fg(Color::Cyan))
.data(&self.latency_history.iter().enumerate().map(|(i, &y)| (i as f64, y)).collect::<Vec<_>>()),
])
.block(
Block::default()
.title("Real-time Order Latency")
.borders(Borders::ALL)
)
.x_axis(
Axis::default()
.title("Time")
.bounds([0.0, 100.0])
.style(Style::default().fg(Color::Gray))
)
.y_axis(
Axis::default()
.title("Latency (μs)")
.bounds([0.0, 1000.0])
.style(Style::default().fg(Color::Gray))
);
frame.render_widget(latency_chart, chunks[0]);
// Bottom section: P50/P95/P99 statistics table
let rows: Vec<Row> = self.order_ack_stats
.iter()
.map(|(venue, stats)| {
Row::new(vec![
venue.clone(),
format!("{:.1}", stats.p50_us),
format!("{:.1}", stats.p95_us),
format!("{:.1}", stats.p99_us),
format!("{:.1}", stats.max_us),
stats.count.to_string(),
])
})
.collect();
let latency_table = Table::new(rows)
.header(
Row::new(vec!["Venue", "P50 (μs)", "P95 (μs)", "P99 (μs)", "Max (μs)", "Count"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
)
.block(
Block::default()
.title("Order Acknowledgment Latency Statistics")
.borders(Borders::ALL)
)
.widths(&[
Constraint::Percentage(20),
Constraint::Percentage(16),
Constraint::Percentage(16),
Constraint::Percentage(16),
Constraint::Percentage(16),
Constraint::Percentage(16),
]);
frame.render_widget(latency_table, chunks[1]);
}
/// Render throughput analysis tab
fn render_throughput_tab<B: Backend>(&self, frame: &mut Frame<B>, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
// Left: Throughput sparkline
let sparkline = Sparkline::default()
.block(
Block::default()
.title("Message Throughput (msgs/sec)")
.borders(Borders::ALL)
)
.data(&self.throughput_history)
.style(Style::default().fg(Color::Green));
frame.render_widget(sparkline, chunks[0]);
// Right: Current stats
let current_throughput = self.throughput_history.last().copied().unwrap_or(0);
let avg_throughput = if !self.throughput_history.is_empty() {
self.throughput_history.iter().sum::<u64>() / self.throughput_history.len() as u64
} else {
0
};
let stats_text = vec![
Line::from(vec![
Span::styled("Current: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} msgs/sec", current_throughput),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Average: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} msgs/sec", avg_throughput),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Peak: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} msgs/sec", self.throughput_history.iter().max().copied().unwrap_or(0)),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
),
]),
];
let stats_paragraph = Paragraph::new(stats_text)
.block(
Block::default()
.title("Throughput Statistics")
.borders(Borders::ALL)
);
frame.render_widget(stats_paragraph, chunks[1]);
}
/// Render Parquet persistence monitoring tab
fn render_parquet_tab<B: Backend>(&self, frame: &mut Frame<B>, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Top: Buffer utilization gauge
let buffer_gauge = Gauge::default()
.block(
Block::default()
.title("Parquet Buffer Utilization")
.borders(Borders::ALL)
)
.gauge_style(Style::default().fg(Color::Cyan))
.percent(self.parquet_buffer_stats.utilization_percent as u16)
.label(format!(
"{}/{} events ({:.1}%)",
self.parquet_buffer_stats.buffered_events,
self.parquet_buffer_stats.buffer_capacity,
self.parquet_buffer_stats.utilization_percent
));
frame.render_widget(buffer_gauge, chunks[0]);
// Bottom: Detailed statistics
let parquet_stats = vec![
Line::from(vec![
Span::styled("Buffered Events: ", Style::default().fg(Color::Yellow)),
Span::styled(
self.parquet_buffer_stats.buffered_events.to_string(),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Buffer Capacity: ", Style::default().fg(Color::Yellow)),
Span::styled(
self.parquet_buffer_stats.buffer_capacity.to_string(),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Events/Second: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{:.1}", self.parquet_buffer_stats.events_per_second),
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Last Flush: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{}s ago", self.parquet_buffer_stats.last_flush_ago),
Style::default().fg(Color::Gray)
),
]),
];
let parquet_paragraph = Paragraph::new(parquet_stats)
.block(
Block::default()
.title("Parquet Persistence Statistics")
.borders(Borders::ALL)
);
frame.render_widget(parquet_paragraph, chunks[1]);
}
/// Render OpenTelemetry distributed tracing tab
fn render_telemetry_tab<B: Backend>(&self, frame: &mut Frame<B>, area: Rect) {
let items: Vec<ListItem> = self.telemetry_spans
.iter()
.take(10) // Show last 10 spans
.map(|span| {
ListItem::new(vec![
Line::from(vec![
Span::styled(
format!("{} @ {}", span.operation, span.venue),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
),
Span::styled(
format!(" ({:.1}μs)", span.duration_us),
Style::default().fg(if span.duration_us > 100.0 { Color::Red } else { Color::Green })
),
]),
Line::from(vec![
Span::styled("Trace: ", Style::default().fg(Color::Gray)),
Span::styled(&span.trace_id, Style::default().fg(Color::Yellow)),
]),
])
})
.collect();
let telemetry_list = List::new(items)
.block(
Block::default()
.title("Recent OpenTelemetry Spans")
.borders(Borders::ALL)
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
frame.render_widget(telemetry_list, area);
}
/// Render system metrics tab
fn render_system_tab<B: Backend>(&self, frame: &mut Frame<B>, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[0]);
// CPU Usage Gauge
let cpu_gauge = Gauge::default()
.block(
Block::default()
.title("CPU Usage")
.borders(Borders::ALL)
)
.gauge_style(Style::default().fg(Color::Red))
.percent(self.system_metrics.cpu_usage as u16)
.label(format!("{:.1}%", self.system_metrics.cpu_usage));
frame.render_widget(cpu_gauge, top_chunks[0]);
// Memory Usage Gauge
let memory_gauge = Gauge::default()
.block(
Block::default()
.title("Memory Usage")
.borders(Borders::ALL)
)
.gauge_style(Style::default().fg(Color::Blue))
.percent(self.system_metrics.memory_usage as u16)
.label(format!("{:.1}%", self.system_metrics.memory_usage));
frame.render_widget(memory_gauge, top_chunks[1]);
// Network and connection stats
let system_stats = vec![
Line::from(vec![
Span::styled("Network RX: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} MB/s", self.system_metrics.network_rx / 1_000_000),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Network TX: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} MB/s", self.system_metrics.network_tx / 1_000_000),
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Disk I/O: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{} MB/s", self.system_metrics.disk_io / 1_000_000),
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
),
]),
Line::from(vec![
Span::styled("Active Connections: ", Style::default().fg(Color::Yellow)),
Span::styled(
self.system_metrics.active_connections.to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
),
]),
];
let system_paragraph = Paragraph::new(system_stats)
.block(
Block::default()
.title("System Statistics")
.borders(Borders::ALL)
);
frame.render_widget(system_paragraph, chunks[1]);
}
/// Handle tab navigation
pub fn next_tab(&mut self) {
self.selected_tab = (self.selected_tab + 1) % 5;
}
pub fn previous_tab(&mut self) {
self.selected_tab = if self.selected_tab > 0 { self.selected_tab - 1 } else { 4 };
}
// Private update methods
async fn update_order_ack_stats(&mut self) {
// Update with real data from the metrics system
for venue in &["binance", "coinbase", "kraken"] {
for order_type in &["market", "limit"] {
if let Some(stats) = get_order_ack_percentiles(venue, order_type) {
let key = format!("{}_{}", venue, order_type);
self.order_ack_stats.insert(key, stats);
}
}
}
}
async fn update_parquet_buffer_stats(&mut self) {
let buffer = MARKET_DATA_BUFFER.read();
self.parquet_buffer_stats.buffered_events = buffer.len();
self.parquet_buffer_stats.utilization_percent =
(buffer.len() as f64 / buffer.capacity() as f64) * 100.0;
// Simulate events per second (would be calculated from actual metrics)
self.parquet_buffer_stats.events_per_second = 1250.0 + (rand::random::<f64>() * 500.0);
self.parquet_buffer_stats.last_flush_ago = self.update_counter % 60;
}
async fn update_telemetry_spans(&mut self) {
// In a real implementation, this would query the telemetry system
// For now, simulate some spans
if self.update_counter % 5 == 0 {
let span = SpanInfo {
operation: "submit_order".to_string(),
venue: "binance".to_string(),
duration_us: 45.0 + (rand::random::<f64>() * 100.0),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64,
trace_id: format!("trace_{}", self.update_counter),
span_id: format!("span_{}", self.update_counter),
};
self.telemetry_spans.insert(0, span);
if self.telemetry_spans.len() > 50 {
self.telemetry_spans.truncate(50);
}
}
}
async fn update_system_metrics(&mut self) {
// Simulate system metrics (would be from actual system monitoring)
self.system_metrics.cpu_usage = 25.0 + (rand::random::<f64>() * 40.0);
self.system_metrics.memory_usage = 60.0 + (rand::random::<f64>() * 20.0);
self.system_metrics.network_rx = 10_000_000 + (rand::random::<u64>() % 5_000_000);
self.system_metrics.network_tx = 8_000_000 + (rand::random::<u64>() % 4_000_000);
self.system_metrics.disk_io = 2_000_000 + (rand::random::<u64>() % 1_000_000);
self.system_metrics.active_connections = 150 + (rand::random::<u32>() % 50);
}
fn get_current_latency_us(&self) -> f64 {
// Get the most recent P95 latency from order ack stats
self.order_ack_stats
.values()
.map(|stats| stats.p95_us as f64)
.fold(0.0, f64::max)
.max(10.0 + (rand::random::<f64>() * 200.0))
}
fn get_current_throughput(&self) -> u64 {
// Simulate current throughput
5000 + (rand::random::<u64>() % 3000)
}
}
/// Integration with main TLI dashboard
pub fn integrate_observability_dashboard() -> ObservabilityDashboard {
info!("Initializing enhanced observability dashboard");
// Initialize telemetry if not already done
let _tracer = &*TELEMETRY_TRACER;
ObservabilityDashboard::new()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[tokio::test]
async fn test_observability_dashboard_creation() {
let mut dashboard = ObservabilityDashboard::new();
assert_eq!(dashboard.selected_tab, 0);
assert!(dashboard.latency_history.is_empty());
}
#[tokio::test]
async fn test_dashboard_update() {
let mut dashboard = ObservabilityDashboard::new();
let result = dashboard.update().await;
assert!(result.is_ok());
assert!(dashboard.update_counter > 0);
}
#[test]
fn test_tab_navigation() {
let mut dashboard = ObservabilityDashboard::new();
dashboard.next_tab();
assert_eq!(dashboard.selected_tab, 1);
dashboard.previous_tab();
assert_eq!(dashboard.selected_tab, 0);
dashboard.previous_tab();
assert_eq!(dashboard.selected_tab, 4); // Wraps around
}
}

View File

@@ -1,54 +0,0 @@
//! Performance Dashboard Implementation
use super::Dashboard;
use crate::dashboard::events::DashboardEvent;
use anyhow::Result;
use crossterm::event::KeyEvent;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph},
};
use tokio::sync::mpsc;
pub struct PerformanceDashboard {
_event_sender: mpsc::Sender<DashboardEvent>,
needs_redraw: bool,
}
impl PerformanceDashboard {
pub const fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
Self {
_event_sender,
needs_redraw: true,
}
}
}
impl Dashboard for PerformanceDashboard {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let paragraph = Paragraph::new("Performance Dashboard\n\nTotal Return: +15.67% YTD\nDaily PnL: +$2,500\nSharpe Ratio: 1.85\nWin Rate: 66.8%\nTotal Trades: 247")
.block(Block::default().borders(Borders::ALL).title("Performance Dashboard"));
frame.render_widget(paragraph, area);
self.needs_redraw = false;
Ok(())
}
fn handle_input(&mut self, _key: KeyEvent) -> Result<Option<DashboardEvent>> {
Ok(None)
}
fn update(&mut self, _event: DashboardEvent) -> Result<()> {
Ok(())
}
fn title(&self) -> &str {
"Performance"
}
fn shortcut_key(&self) -> char {
'p'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}

View File

@@ -1,187 +0,0 @@
//! Risk Dashboard Implementation
//!
//! Real-time risk monitoring dashboard showing:
//! - `VaR` metrics
//! - Position limits
//! - Drawdown monitor
//! - Safety controls
use super::Dashboard;
use crate::dashboard::events::DashboardEvent;
use crate::dashboard::events::RiskMetricsEvent;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Gauge, Paragraph},
};
use tokio::sync::mpsc;
pub struct RiskDashboard {
_event_sender: mpsc::Sender<DashboardEvent>,
risk_metrics: Option<RiskMetricsEvent>,
needs_redraw: bool,
emergency_stop_armed: bool,
}
impl RiskDashboard {
pub const fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
Self {
_event_sender,
risk_metrics: None,
needs_redraw: true,
emergency_stop_armed: false,
}
}
fn render_var_metrics(&self, frame: &mut Frame, area: Rect) {
let text = if let Some(metrics) = &self.risk_metrics {
format!(
"VaR Metrics\n\n1-Day: ${:.0}\n5-Day: ${:.0}\n30-Day: ${:.0}\nConfidence: 95%\nMethod: Monte Carlo\nLast Calc: Now",
metrics.var_1d,
metrics.var_5d,
metrics.var_5d * 2.0, // Approximate 30-day
)
} else {
"VaR Metrics\n\n1-Day: $5,000\n5-Day: $8,000\n30-Day: $12,000\nConfidence: 95%\nMethod: Monte Carlo\nLast Calc: 14:30".to_owned()
};
let paragraph =
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("VaR Metrics"));
frame.render_widget(paragraph, area);
}
fn render_position_limits(&self, frame: &mut Frame, area: Rect) {
let text = "Position Limits\n\nMax Per Symbol:\n$100K (50% used)\n\nTotal Exposure:\n$2.5M (80% used)\n\nConcentration:\n25% (limit 30%)";
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title("Position Limits"),
);
frame.render_widget(paragraph, area);
}
fn render_drawdown_monitor(&self, frame: &mut Frame, area: Rect) {
let current_dd = if let Some(metrics) = &self.risk_metrics {
metrics.current_drawdown
} else {
-0.025 // -2.5%
};
let dd_percentage = (current_dd * 100.0).abs();
let dd_ratio = (dd_percentage / 15.0).min(1.0); // Max 15% drawdown
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title("Drawdown Monitor"),
)
.gauge_style(if dd_ratio > 0.8 {
Style::default().fg(Color::Red)
} else if dd_ratio > 0.5 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
})
.ratio(dd_ratio)
.label(format!("Current: -{:.1}%", dd_percentage));
frame.render_widget(gauge, area);
}
fn render_safety_controls(&self, frame: &mut Frame, area: Rect) {
let status_color = if self.emergency_stop_armed {
Color::Red
} else {
Color::Green
};
let text = format!(
"Safety Controls\n\nKill Switch:\n\u{25cf}\u{25cf}\u{25cf}\u{25cf} {}\n\nAuto Recovery:\n\u{25cf}\u{25cf}\u{25cf}\u{25cf} ENABLED\n\nLast Test: 14:00\n\n[E] Emergency Stop\n[R] Reset Controls",
if self.emergency_stop_armed { "ARMED" } else { "ACTIVE" }
);
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title("Safety Controls")
.border_style(Style::default().fg(status_color)),
);
frame.render_widget(paragraph, area);
}
}
impl Dashboard for RiskDashboard {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
// Create a 2x2 grid layout
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let top_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[0_usize]);
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1_usize]);
self.render_var_metrics(frame, top_cols[0_usize]);
self.render_position_limits(frame, top_cols[1_usize]);
self.render_drawdown_monitor(frame, bottom_cols[0_usize]);
self.render_safety_controls(frame, bottom_cols[1_usize]);
self.needs_redraw = false;
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
match key.code {
KeyCode::Char('e') | KeyCode::Char('E') => {
self.emergency_stop_armed = !self.emergency_stop_armed;
self.needs_redraw = true;
if self.emergency_stop_armed {
return Ok(Some(DashboardEvent::TriggerEmergencyStop));
}
},
KeyCode::Char('r') | KeyCode::Char('R') => {
self.emergency_stop_armed = false;
self.needs_redraw = true;
},
_ => {},
}
Ok(None)
}
fn update(&mut self, event: DashboardEvent) -> Result<()> {
if let DashboardEvent::RiskMetricsUpdate(metrics) = event {
self.risk_metrics = Some(metrics);
self.needs_redraw = true;
}
Ok(())
}
fn title(&self) -> &str {
"Risk"
}
fn shortcut_key(&self) -> char {
'r'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}

View File

@@ -1,376 +0,0 @@
//! Trading Dashboard Implementation
//!
//! Real-time trading dashboard showing:
//! - Market data feeds
//! - Active positions
//! - Order book
//! - Recent executions
//! - Order entry interface
use super::{Dashboard, DashboardEvent};
use crate::dashboard::events::{ExecutionEvent, MarketDataDisplayEvent, PositionEvent};
use anyhow::Result;
use common::{
HftTimestamp, Order as OrderRequest, OrderEvent, OrderId, OrderSide, OrderStatus, OrderType,
Quantity, Symbol, TimeInForce,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
};
use std::collections::HashMap;
use tokio::sync::mpsc;
pub struct TradingDashboard {
_event_sender: mpsc::Sender<DashboardEvent>,
market_data: HashMap<String, MarketDataDisplayEvent>,
positions: HashMap<String, PositionEvent>,
recent_orders: Vec<OrderEvent>,
recent_executions: Vec<ExecutionEvent>,
table_state: TableState,
needs_redraw: bool,
selected_symbol: String,
}
impl TradingDashboard {
pub fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
let mut state = TableState::default();
state.select(Some(0));
Self {
_event_sender,
market_data: HashMap::new(),
positions: HashMap::new(),
recent_orders: Vec::new(),
recent_executions: Vec::new(),
table_state: state,
needs_redraw: true,
selected_symbol: "AAPL".to_owned(),
}
}
fn render_market_data(&self, frame: &mut Frame, area: Rect) {
let mut rows = vec![];
// Add sample data if no real data available
if self.market_data.is_empty() {
rows.extend(vec![
Row::new(vec![
Cell::from("AAPL"),
Cell::from("$150.25"),
Cell::from("+1.25%"),
Cell::from("1_usize,250_usize,000"),
]),
Row::new(vec![
Cell::from("TSLA"),
Cell::from("$800.50"),
Cell::from("-0.75%"),
Cell::from("850_usize,000"),
]),
Row::new(vec![
Cell::from("SPY"),
Cell::from("$420.10"),
Cell::from("+0.45%"),
Cell::from("5_usize,500_usize,000"),
]),
]);
} else {
for (symbol, data) in &self.market_data {
rows.push(Row::new(vec![
Cell::from(symbol.clone()),
Cell::from(format!("${:.2}", data.price)),
Cell::from(format!("{:.2}%", data.change_percent.unwrap_or(0.0))),
Cell::from(format!("{}", data.volume)),
]));
}
}
let table = Table::new(
rows,
[
Constraint::Length(8), // Symbol
Constraint::Length(10), // Price
Constraint::Length(8), // Change
Constraint::Length(12), // Volume
],
)
.header(
Row::new(vec!["Symbol", "Price", "Change", "Volume"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.block(Block::default().borders(Borders::ALL).title("Market Data"))
.highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(table, area, &mut self.table_state.clone());
}
fn render_positions(&self, frame: &mut Frame, area: Rect) {
let mut rows = vec![];
// Add sample data if no real data available
if self.positions.is_empty() {
rows.extend(vec![
Row::new(vec![
Cell::from("AAPL"),
Cell::from("1000"),
Cell::from("$150.00"),
Cell::from("+$250.00"),
]),
Row::new(vec![
Cell::from("TSLA"),
Cell::from("-500"),
Cell::from("$800.00"),
Cell::from("-$375.00"),
]),
]);
} else {
for (symbol, position) in &self.positions {
rows.push(Row::new(vec![
Cell::from(symbol.clone()),
Cell::from(format!("{:.0}", position.quantity)),
Cell::from(format!("${:.2}", position.avg_price)),
Cell::from(format!("${:.2}", position.unrealized_pnl)),
]));
}
}
let table = Table::new(
rows,
[
Constraint::Length(8), // Symbol
Constraint::Length(8), // Quantity
Constraint::Length(10), // Avg Price
Constraint::Length(12), // PnL
],
)
.header(
Row::new(vec!["Symbol", "Qty", "Avg Price", "Unrealized PnL"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.block(Block::default().borders(Borders::ALL).title("Positions"));
frame.render_widget(table, area);
}
fn render_order_entry(&self, frame: &mut Frame, area: Rect) {
let text = format!(
"Order Entry\n\nSymbol: {}\nSide: [BUY \u{25bc}]\nQty: [500 ]\nPrice: [MKT \u{25bc}]\n\n[F1] Submit Order\n[F2] Cancel\n\nLast Order: BUY 100 AAPL @MKT",
self.selected_symbol
);
let paragraph = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title("Order Entry"))
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_recent_executions(&self, frame: &mut Frame, area: Rect) {
let mut rows = vec![];
// Add sample data if no real data available
if self.recent_executions.is_empty() {
rows.extend(vec![
Row::new(vec![
Cell::from("14:35:21"),
Cell::from("AAPL"),
Cell::from("BUY"),
Cell::from("500"),
Cell::from("$150.25"),
]),
Row::new(vec![
Cell::from("14:34:15"),
Cell::from("TSLA"),
Cell::from("SELL"),
Cell::from("200"),
Cell::from("$800.75"),
]),
]);
} else {
for execution in &self.recent_executions {
let time = chrono::DateTime::from_timestamp(execution.timestamp, 0)
.unwrap_or_default()
.format("%H:%M:%S")
.to_string();
rows.push(Row::new(vec![
Cell::from(time),
Cell::from(execution.symbol.clone()),
Cell::from(execution.side.to_string()),
Cell::from(format!("{:.0}", execution.quantity)),
Cell::from(format!("${:.2}", execution.price)),
]));
}
}
let table = Table::new(
rows,
[
Constraint::Length(8), // Time
Constraint::Length(8), // Symbol
Constraint::Length(6), // Side
Constraint::Length(8), // Quantity
Constraint::Length(10), // Price
],
)
.header(
Row::new(vec!["Time", "Symbol", "Side", "Qty", "Price"]).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.block(
Block::default()
.borders(Borders::ALL)
.title("Recent Executions"),
);
frame.render_widget(table, area);
}
}
impl Dashboard for TradingDashboard {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
// Create a 2x2 grid layout for the trading dashboard
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let top_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(rows[0_usize]);
let bottom_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1_usize]);
// Render each section
self.render_market_data(frame, top_cols[0_usize]);
self.render_positions(frame, top_cols[1_usize]);
self.render_order_entry(frame, bottom_cols[0_usize]);
self.render_recent_executions(frame, bottom_cols[1_usize]);
self.needs_redraw = false;
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
match key.code {
KeyCode::Up => {
if let Some(selected) = self.table_state.selected() {
if selected > 0 {
self.table_state.select(Some(selected - 1));
self.needs_redraw = true;
}
}
},
KeyCode::Down => {
if let Some(selected) = self.table_state.selected() {
self.table_state.select(Some(selected + 1));
self.needs_redraw = true;
}
},
KeyCode::F(1) => {
// Submit order
let order_request = OrderRequest {
id: OrderId::new(),
client_order_id: Some(format!("tli-{}", chrono::Utc::now().timestamp())),
broker_order_id: None,
account_id: None,
symbol: Symbol::from(self.selected_symbol.clone()),
side: OrderSide::Buy,
order_type: OrderType::Market,
status: OrderStatus::Pending,
time_in_force: TimeInForce::Day,
quantity: Quantity::from_f64(500.0).unwrap_or(Quantity::ZERO),
filled_quantity: Quantity::ZERO,
remaining_quantity: Quantity::from_f64(500.0).unwrap_or(Quantity::ZERO),
price: None,
stop_price: None,
average_fill_price: None,
exchange_order_id: None,
average_price: None,
avg_fill_price: None, // Database compatibility alias
parent_id: None,
execution_algorithm: None,
execution_params: serde_json::json!({}),
stop_loss: None,
take_profit: None,
created_at: HftTimestamp::now_or_zero(),
updated_at: Some(HftTimestamp::now_or_zero()),
expires_at: None,
metadata: serde_json::json!({}),
};
return Ok(Some(DashboardEvent::PlaceOrder(order_request)));
},
KeyCode::Enter => {
// Switch selected symbol based on table selection
if let Some(selected) = self.table_state.selected() {
let symbols = ["AAPL", "TSLA", "SPY"];
if selected < symbols.len() {
self.selected_symbol = symbols[selected].to_owned();
self.needs_redraw = true;
}
}
},
_ => {},
}
Ok(None)
}
fn update(&mut self, event: DashboardEvent) -> Result<()> {
match event {
DashboardEvent::MarketDataUpdate(data) => {
self.market_data.insert(data.symbol.clone(), data);
self.needs_redraw = true;
},
DashboardEvent::PositionUpdate(position) => {
self.positions.insert(position.symbol.clone(), position);
self.needs_redraw = true;
},
DashboardEvent::OrderUpdate(order) => {
self.recent_orders.push(order);
if self.recent_orders.len() > 10 {
self.recent_orders.remove(0);
}
self.needs_redraw = true;
},
DashboardEvent::ExecutionUpdate(execution) => {
self.recent_executions.push(execution);
if self.recent_executions.len() > 10 {
self.recent_executions.remove(0);
}
self.needs_redraw = true;
},
_ => {},
}
Ok(())
}
fn title(&self) -> &str {
"Trading"
}
fn shortcut_key(&self) -> char {
't'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}

View File

@@ -1,370 +0,0 @@
//! Vault Status Dashboard Component
use super::{Dashboard, DashboardEvent};
use anyhow::Result;
use crossterm::event::KeyEvent;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::RwLock;
/// Vault connection statistics
#[derive(Debug, Clone)]
pub struct VaultStats {
pub health_status: VaultHealthStatus,
pub connection_count: u32,
pub cache_hit_ratio: f64,
pub credentials_cached: u32,
pub services_discovered: u32,
pub last_health_check: Option<chrono::DateTime<chrono::Utc>>,
pub rotation_stats: RotationStats,
}
#[derive(Debug, Clone, PartialEq)]
pub enum VaultHealthStatus {
Healthy,
Degraded,
Unhealthy,
Unknown,
}
#[derive(Debug, Clone)]
pub struct RotationStats {
pub total_rotations: u32,
pub successful_rotations: u32,
pub failed_rotations: u32,
pub pending_rotations: u32,
}
impl Default for VaultStats {
fn default() -> Self {
Self {
health_status: VaultHealthStatus::Unknown,
connection_count: 0,
cache_hit_ratio: 0.0,
credentials_cached: 0,
services_discovered: 0,
last_health_check: None,
rotation_stats: RotationStats {
total_rotations: 0,
successful_rotations: 0,
failed_rotations: 0,
pending_rotations: 0,
},
}
}
}
/// Vault status dashboard widget
pub struct VaultStatusWidget {
stats: Arc<RwLock<VaultStats>>,
_event_sender: mpsc::Sender<DashboardEvent>,
needs_redraw: bool,
}
impl VaultStatusWidget {
pub fn new(_event_sender: mpsc::Sender<DashboardEvent>) -> Self {
Self {
stats: Arc::new(RwLock::new(VaultStats::default())),
_event_sender,
needs_redraw: true,
}
}
/// Update vault statistics
pub async fn update_stats(&self, stats: VaultStats) {
let mut current_stats = self.stats.write().await;
*current_stats = stats;
}
/// Get current vault statistics
pub async fn get_stats(&self) -> VaultStats {
self.stats.read().await.clone()
}
/// Render vault status widget in a specific area
pub async fn render_widget(&self, frame: &mut Frame<'_>, area: Rect) -> Result<()> {
let stats = self.stats.read().await;
// Create main layout
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Health status
Constraint::Length(7), // Connection stats
Constraint::Min(3), // Rotation status
])
.split(area);
// Health Status
self.render_health_status(frame, chunks[0], &stats)?;
// Connection Statistics
self.render_connection_stats(frame, chunks[1], &stats)?;
// Rotation Statistics
self.render_rotation_stats(frame, chunks[2], &stats)?;
Ok(())
}
fn render_health_status(
&self,
frame: &mut Frame,
area: Rect,
stats: &VaultStats,
) -> Result<()> {
let (status_text, status_color) = match stats.health_status {
VaultHealthStatus::Healthy => ("HEALTHY", Color::Green),
VaultHealthStatus::Degraded => ("DEGRADED", Color::Yellow),
VaultHealthStatus::Unhealthy => ("UNHEALTHY", Color::Red),
VaultHealthStatus::Unknown => ("UNKNOWN", Color::Gray),
};
let last_check = if let Some(timestamp) = stats.last_health_check {
format!(" (Last: {})", timestamp.format("%H:%M:%S"))
} else {
" (Never checked)".to_owned()
};
let paragraph = Paragraph::new(format!("Status: {}{}", status_text, last_check))
.block(
Block::default()
.borders(Borders::ALL)
.title("Vault Health")
.border_style(Style::default().fg(status_color)),
)
.style(Style::default().fg(status_color))
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
Ok(())
}
fn render_connection_stats(
&self,
frame: &mut Frame,
area: Rect,
stats: &VaultStats,
) -> Result<()> {
let items = vec![
ListItem::new(format!("Connections: {}", stats.connection_count)),
ListItem::new(format!(
"Cache Hit Ratio: {:.1}%",
stats.cache_hit_ratio * 100.0
)),
ListItem::new(format!("Cached Credentials: {}", stats.credentials_cached)),
ListItem::new(format!(
"Services Discovered: {}",
stats.services_discovered
)),
];
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Connection Statistics"),
)
.style(Style::default().fg(Color::White));
frame.render_widget(list, area);
Ok(())
}
fn render_rotation_stats(
&self,
frame: &mut Frame,
area: Rect,
stats: &VaultStats,
) -> Result<()> {
let rotation_stats = &stats.rotation_stats;
let success_rate = if rotation_stats.total_rotations > 0 {
rotation_stats.successful_rotations as f64 / rotation_stats.total_rotations as f64
} else {
0.0
};
// Split area for gauge and list
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Success rate gauge
Constraint::Min(3), // Stats list
])
.split(area);
// Success rate gauge
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title("Rotation Success Rate"),
)
.gauge_style(if success_rate > 0.8 {
Style::default().fg(Color::Green)
} else if success_rate > 0.5 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Red)
})
.ratio(success_rate)
.label(format!("{:.1}%", success_rate * 100.0));
frame.render_widget(gauge, chunks[0]);
// Rotation statistics list
let items = vec![
ListItem::new(format!(
"Total Rotations: {}",
rotation_stats.total_rotations
)),
ListItem::new(format!(
"Successful: {}",
rotation_stats.successful_rotations
))
.style(Style::default().fg(Color::Green)),
ListItem::new(format!("Failed: {}", rotation_stats.failed_rotations))
.style(Style::default().fg(Color::Red)),
ListItem::new(format!("Pending: {}", rotation_stats.pending_rotations))
.style(Style::default().fg(Color::Yellow)),
];
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Credential Rotations"),
)
.style(Style::default().fg(Color::White));
frame.render_widget(list, chunks[1]);
Ok(())
}
}
impl Dashboard for VaultStatusWidget {
fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
// This would be called for a full dashboard view
let block = Block::default()
.borders(Borders::ALL)
.title("Vault Status Dashboard");
let inner_area = block.inner(area);
frame.render_widget(block, area);
// Use async runtime to render widget
let rt = tokio::runtime::Handle::current();
rt.block_on(self.render_widget(frame, inner_area))?;
self.needs_redraw = false;
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<Option<DashboardEvent>> {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('r') => {
// Refresh vault stats
self.needs_redraw = true;
Ok(Some(DashboardEvent::RefreshData))
},
KeyCode::Char('h') => {
// Show help
Ok(Some(DashboardEvent::ShowHelp("Vault Status".to_owned())))
},
_ => Ok(None),
}
}
fn update(&mut self, event: DashboardEvent) -> Result<()> {
match event {
DashboardEvent::RefreshData => {
self.needs_redraw = true;
},
DashboardEvent::VaultStatusUpdate(stats) => {
// Update stats in background since we can't use async in trait method
let stats_clone = self.stats.clone();
tokio::spawn(async move {
let mut current_stats = stats_clone.write().await;
*current_stats = stats;
});
self.needs_redraw = true;
},
_ => {},
}
Ok(())
}
fn title(&self) -> &str {
"Vault Status"
}
fn shortcut_key(&self) -> char {
'v'
}
fn needs_redraw(&self) -> bool {
self.needs_redraw
}
fn mark_drawn(&mut self) {
self.needs_redraw = false;
}
}
/// Helper function to get status color for health status
pub const fn get_vault_status_color(status: &VaultHealthStatus) -> Color {
match status {
VaultHealthStatus::Healthy => Color::Green,
VaultHealthStatus::Degraded => Color::Yellow,
VaultHealthStatus::Unhealthy => Color::Red,
VaultHealthStatus::Unknown => Color::Gray,
}
}
/// Helper function to get status symbol for health status
pub const fn get_vault_status_symbol(status: &VaultHealthStatus) -> &'static str {
match status {
VaultHealthStatus::Healthy => "\u{25cf}", // Green circle
VaultHealthStatus::Degraded => "\u{25d0}", // Half circle
VaultHealthStatus::Unhealthy => "\u{25cb}", // Empty circle
VaultHealthStatus::Unknown => "?", // Question mark
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vault_stats_default() {
let stats = VaultStats::default();
assert_eq!(stats.health_status, VaultHealthStatus::Unknown);
assert_eq!(stats.connection_count, 0);
assert_eq!(stats.cache_hit_ratio, 0.0);
}
#[test]
fn test_vault_status_colors() {
assert_eq!(
get_vault_status_color(&VaultHealthStatus::Healthy),
Color::Green
);
assert_eq!(
get_vault_status_color(&VaultHealthStatus::Degraded),
Color::Yellow
);
assert_eq!(
get_vault_status_color(&VaultHealthStatus::Unhealthy),
Color::Red
);
assert_eq!(
get_vault_status_color(&VaultHealthStatus::Unknown),
Color::Gray
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
//! Dashboard implementations for TLI
//!
//! This module contains the actual dashboard implementations that are used
//! by the dashboard framework.
pub mod config_manager;
pub mod configuration;
// Re-export commonly used dashboard types
pub use configuration::ConfigurationDashboard;

View File

@@ -1,522 +0,0 @@
//! Consolidated error handling for the TLI module using CommonError
//!
//! This module demonstrates the consolidated error handling pattern
//! using the common error system across all Foxhunt TLI services.
// REMOVED: All pub use statements eliminated per cleanup requirements
// Use direct imports: common::error::{CommonError, CommonResult, ErrorCategory, RetryStrategy, ErrorSeverity}
use tonic::{Code, Status};
/// Result type for TLI operations using CommonError
pub type TliResult<T> = common::error::CommonResult<T>;
/// TLI module specific error extensions
///
/// For cases where we need domain-specific error information beyond CommonError
#[derive(Debug, thiserror::Error)]
pub enum TliServiceError {
/// Common error with context
#[error("TLI service error: {0}")]
Common(#[from] common::error::CommonError),
/// gRPC connection specific error with service context
#[error("gRPC connection error: {service} at {endpoint} - {message}")]
GrpcConnection {
service: String,
endpoint: String,
message: String,
},
/// Order validation error with order context
#[error("Order validation error: {order_id} - {field}: {message}")]
OrderValidation {
order_id: String,
field: String,
message: String,
},
/// Dashboard rendering error
#[error("Dashboard rendering error: {widget} - {message}")]
DashboardRendering {
widget: String,
message: String,
},
/// Event buffer overflow
#[error("Event buffer overflow: {buffer_name} capacity {capacity} exceeded")]
EventBufferOverflow {
buffer_name: String,
capacity: usize,
},
/// Configuration hot-reload error
#[error("Configuration hot-reload error: {config_key} - {message}")]
ConfigHotReload {
config_key: String,
message: String,
},
/// Certificate validation error
#[error("Certificate validation error: {cert_type} - {message}")]
CertificateValidation {
cert_type: String,
message: String,
},
/// Trading service communication error
#[error("Trading service error: {operation} - {message}")]
TradingService {
operation: String,
message: String,
},
/// ML service communication error
#[error("ML service error: {operation} - {message}")]
MLService {
operation: String,
message: String,
},
/// Backtesting service communication error
#[error("Backtesting service error: {operation} - {message}")]
BacktestingService {
operation: String,
message: String,
},
}
impl TliServiceError {
/// Convert to CommonError for metrics and monitoring
pub fn to_common_error(self) -> common::error::CommonError {
match self {
TliServiceError::Common(err) => err,
TliServiceError::GrpcConnection { service, endpoint, message } => {
common::error::CommonError::connection(
format!("grpc://{}:{}", service, endpoint),
message
)
}
TliServiceError::OrderValidation { order_id, field, message } => {
common::error::CommonError::validation(
format!("order[{}].{}", order_id, field),
message
)
}
TliServiceError::DashboardRendering { widget, message } => {
common::error::CommonError::internal(format!("Dashboard widget {}: {}", widget, message))
}
TliServiceError::EventBufferOverflow { buffer_name, capacity } => {
common::error::CommonError::resource_exhausted(
format!("Event buffer {} (capacity: {})", buffer_name, capacity)
)
}
TliServiceError::ConfigHotReload { config_key, message } => {
common::error::CommonError::config(format!("Hot-reload {} failed: {}", config_key, message))
}
TliServiceError::CertificateValidation { cert_type, message } => {
common::error::CommonError::authentication(format!("Certificate {}: {}", cert_type, message))
}
TliServiceError::TradingService { operation, message } => {
common::error::CommonError::service(
common::error::ErrorCategory::Trading,
format!("Trading service {}: {}", operation, message)
)
}
TliServiceError::MLService { operation, message } => {
common::error::CommonError::service(
common::error::ErrorCategory::ML,
format!("ML service {}: {}", operation, message)
)
}
TliServiceError::BacktestingService { operation, message } => {
common::error::CommonError::service(
common::error::ErrorCategory::System,
format!("Backtesting service {}: {}", operation, message)
)
}
}
}
/// Get error category for metrics
pub fn category(&self) -> common::error::ErrorCategory {
match self {
TliServiceError::Common(_) => self.to_common_error().category(),
TliServiceError::TradingService { .. } => common::error::ErrorCategory::Trading,
TliServiceError::MLService { .. } => common::error::ErrorCategory::ML,
TliServiceError::BacktestingService { .. } => common::error::ErrorCategory::System,
TliServiceError::GrpcConnection { .. } => common::error::ErrorCategory::Network,
TliServiceError::OrderValidation { .. } => common::error::ErrorCategory::Validation,
TliServiceError::CertificateValidation { .. } => common::error::ErrorCategory::Security,
_ => common::error::ErrorCategory::System,
}
}
/// Get error severity
pub fn severity(&self) -> common::error::ErrorSeverity {
match self {
TliServiceError::CertificateValidation { .. } => common::error::ErrorSeverity::Critical,
TliServiceError::ConfigHotReload { .. } => common::error::ErrorSeverity::Error,
TliServiceError::TradingService { .. } => common::error::ErrorSeverity::Error,
TliServiceError::MLService { .. } => common::error::ErrorSeverity::Error,
TliServiceError::BacktestingService { .. } => common::error::ErrorSeverity::Error,
TliServiceError::GrpcConnection { .. } => common::error::ErrorSeverity::Warn,
TliServiceError::EventBufferOverflow { .. } => common::error::ErrorSeverity::Warn,
TliServiceError::OrderValidation { .. } => common::error::ErrorSeverity::Info,
TliServiceError::DashboardRendering { .. } => common::error::ErrorSeverity::Info,
TliServiceError::Common(_) => self.to_common_error().severity(),
}
}
/// Get retry strategy
pub fn retry_strategy(&self) -> common::error::RetryStrategy {
match self {
// Authentication/security errors should not be retried
TliServiceError::CertificateValidation { .. } => common::error::RetryStrategy::NoRetry,
TliServiceError::OrderValidation { .. } => common::error::RetryStrategy::NoRetry,
// Network/connection errors can be retried with backoff
TliServiceError::GrpcConnection { .. } => common::error::RetryStrategy::Exponential {
base_delay_ms: 500,
max_delay_ms: 5000,
},
TliServiceError::TradingService { .. } => common::error::RetryStrategy::Exponential {
base_delay_ms: 100,
max_delay_ms: 2000,
},
TliServiceError::MLService { .. } => common::error::RetryStrategy::Linear {
base_delay_ms: 1000,
},
TliServiceError::BacktestingService { .. } => common::error::RetryStrategy::Linear {
base_delay_ms: 2000,
},
// System errors can retry with delay
TliServiceError::ConfigHotReload { .. } => common::error::RetryStrategy::Linear {
base_delay_ms: 5000,
},
TliServiceError::EventBufferOverflow { .. } => common::error::RetryStrategy::Linear {
base_delay_ms: 1000,
},
TliServiceError::DashboardRendering { .. } => common::error::RetryStrategy::Immediate,
TliServiceError::Common(_) => self.to_common_error().retry_strategy(),
}
}
/// Check if error is retryable
pub fn is_retryable(&self) -> bool {
!matches!(self.retry_strategy(), common::error::RetryStrategy::NoRetry)
}
/// Get error code for monitoring
pub fn error_code(&self) -> &'static str {
match self {
TliServiceError::Common(_) => "TLI_COMMON_ERROR",
TliServiceError::GrpcConnection { .. } => "TLI_GRPC_CONNECTION_ERROR",
TliServiceError::OrderValidation { .. } => "TLI_ORDER_VALIDATION_ERROR",
TliServiceError::DashboardRendering { .. } => "TLI_DASHBOARD_RENDERING_ERROR",
TliServiceError::EventBufferOverflow { .. } => "TLI_EVENT_BUFFER_OVERFLOW",
TliServiceError::ConfigHotReload { .. } => "TLI_CONFIG_HOT_RELOAD_ERROR",
TliServiceError::CertificateValidation { .. } => "TLI_CERTIFICATE_VALIDATION_ERROR",
TliServiceError::TradingService { .. } => "TLI_TRADING_SERVICE_ERROR",
TliServiceError::MLService { .. } => "TLI_ML_SERVICE_ERROR",
TliServiceError::BacktestingService { .. } => "TLI_BACKTESTING_SERVICE_ERROR",
}
}
}
/// Convert standard errors to CommonError for consistent handling
impl From<std::io::Error> for TliServiceError {
fn from(err: std::io::Error) -> Self {
TliServiceError::Common(common::error::CommonError::network(format!("IO error: {}", err)))
}
}
impl From<serde_json::Error> for TliServiceError {
fn from(err: serde_json::Error) -> Self {
TliServiceError::Common(common::error::CommonError::serialization(format!("JSON error: {}", err)))
}
}
impl From<anyhow::Error> for TliServiceError {
fn from(err: anyhow::Error) -> Self {
TliServiceError::Common(common::error::CommonError::internal(format!("Anyhow error: {}", err)))
}
}
impl From<tonic::Status> for TliServiceError {
fn from(status: tonic::Status) -> Self {
let message = status.message().to_string();
match status.code() {
Code::InvalidArgument => TliServiceError::Common(common::error::CommonError::validation("request", message)),
Code::NotFound => TliServiceError::Common(common::error::CommonError::not_found("resource", message)),
Code::PermissionDenied => TliServiceError::Common(common::error::CommonError::authorization(message)),
Code::Unauthenticated => TliServiceError::Common(common::error::CommonError::authentication(message)),
Code::ResourceExhausted => TliServiceError::Common(common::error::CommonError::rate_limited(message)),
Code::FailedPrecondition => TliServiceError::Common(common::error::CommonError::validation("precondition", message)),
Code::Unavailable => TliServiceError::Common(common::error::CommonError::service_unavailable("grpc_service", message)),
Code::DeadlineExceeded => TliServiceError::Common(common::error::CommonError::timeout(5000, 2000)),
Code::Internal => TliServiceError::Common(common::error::CommonError::internal(message)),
_ => TliServiceError::Common(common::error::CommonError::internal(format!("gRPC error: {}", message))),
}
}
}
/// Enhanced gRPC Status conversion with proper error mapping
impl From<TliServiceError> for tonic::Status {
fn from(err: TliServiceError) -> Self {
match err {
TliServiceError::OrderValidation { order_id, field, message } => {
tonic::Status::invalid_argument(format!("Order {} field {}: {}", order_id, field, message))
}
TliServiceError::CertificateValidation { cert_type, message } => {
tonic::Status::unauthenticated(format!("Certificate {}: {}", cert_type, message))
}
TliServiceError::GrpcConnection { service, endpoint, message } => {
tonic::Status::unavailable(format!("Service {} at {}: {}", service, endpoint, message))
}
TliServiceError::EventBufferOverflow { buffer_name, capacity } => {
tonic::Status::resource_exhausted(format!("Buffer {} capacity {} exceeded", buffer_name, capacity))
}
TliServiceError::ConfigHotReload { config_key, message } => {
tonic::Status::internal(format!("Config {} hot-reload failed: {}", config_key, message))
}
TliServiceError::DashboardRendering { widget, message } => {
tonic::Status::internal(format!("Dashboard widget {}: {}", widget, message))
}
TliServiceError::TradingService { operation, message } => {
tonic::Status::unavailable(format!("Trading service {}: {}", operation, message))
}
TliServiceError::MLService { operation, message } => {
tonic::Status::unavailable(format!("ML service {}: {}", operation, message))
}
TliServiceError::BacktestingService { operation, message } => {
tonic::Status::unavailable(format!("Backtesting service {}: {}", operation, message))
}
TliServiceError::Common(common_err) => common_err.into(),
}
}
}
/// Convenience functions for creating TLI service errors
impl TliServiceError {
/// Create gRPC connection error
pub fn grpc_connection<S: Into<String>, E: Into<String>, M: Into<String>>(
service: S,
endpoint: E,
message: M,
) -> Self {
Self::GrpcConnection {
service: service.into(),
endpoint: endpoint.into(),
message: message.into(),
}
}
/// Create order validation error
pub fn order_validation<O: Into<String>, F: Into<String>, M: Into<String>>(
order_id: O,
field: F,
message: M,
) -> Self {
Self::OrderValidation {
order_id: order_id.into(),
field: field.into(),
message: message.into(),
}
}
/// Create dashboard rendering error
pub fn dashboard_rendering<W: Into<String>, M: Into<String>>(widget: W, message: M) -> Self {
Self::DashboardRendering {
widget: widget.into(),
message: message.into(),
}
}
/// Create event buffer overflow error
pub fn event_buffer_overflow<B: Into<String>>(buffer_name: B, capacity: usize) -> Self {
Self::EventBufferOverflow {
buffer_name: buffer_name.into(),
capacity,
}
}
/// Create config hot-reload error
pub fn config_hot_reload<K: Into<String>, M: Into<String>>(config_key: K, message: M) -> Self {
Self::ConfigHotReload {
config_key: config_key.into(),
message: message.into(),
}
}
/// Create certificate validation error
pub fn certificate_validation<C: Into<String>, M: Into<String>>(cert_type: C, message: M) -> Self {
Self::CertificateValidation {
cert_type: cert_type.into(),
message: message.into(),
}
}
/// Create trading service error
pub fn trading_service<O: Into<String>, M: Into<String>>(operation: O, message: M) -> Self {
Self::TradingService {
operation: operation.into(),
message: message.into(),
}
}
/// Create ML service error
pub fn ml_service<O: Into<String>, M: Into<String>>(operation: O, message: M) -> Self {
Self::MLService {
operation: operation.into(),
message: message.into(),
}
}
/// Create backtesting service error
pub fn backtesting_service<O: Into<String>, M: Into<String>>(operation: O, message: M) -> Self {
Self::BacktestingService {
operation: operation.into(),
message: message.into(),
}
}
/// Create network error using CommonError
pub fn network<M: Into<String>>(message: M) -> Self {
Self::Common(common::error::CommonError::network(message))
}
/// Create authentication error using CommonError
pub fn authentication<M: Into<String>>(message: M) -> Self {
Self::Common(common::error::CommonError::authentication(message))
}
/// Create configuration error using CommonError
pub fn configuration<M: Into<String>>(message: M) -> Self {
Self::Common(common::error::CommonError::config(message))
}
/// Create validation error using CommonError
pub fn validation<F: Into<String>, M: Into<String>>(field: F, message: M) -> Self {
Self::Common(common::error::CommonError::validation(field, message))
}
/// Create timeout error using CommonError
pub fn timeout(actual_ms: u64, max_ms: u64) -> Self {
Self::Common(common::error::CommonError::timeout(actual_ms, max_ms))
}
/// Create internal error using CommonError
pub fn internal<M: Into<String>>(message: M) -> Self {
Self::Common(common::error::CommonError::internal(message))
}
/// Create not found error using CommonError
pub fn not_found<R: Into<String>, I: Into<String>>(resource: R, identifier: I) -> Self {
Self::Common(common::error::CommonError::not_found(resource, identifier))
}
}
/// Convert to CommonError automatically for interop
impl From<TliServiceError> for common::error::CommonError {
fn from(err: TliServiceError) -> Self {
err.to_common_error()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tli_service_error_categorization() {
let grpc_error = TliServiceError::grpc_connection("trading", "localhost:50051", "Connection refused");
assert_eq!(grpc_error.category(), common::error::ErrorCategory::Network);
assert_eq!(grpc_error.error_code(), "TLI_GRPC_CONNECTION_ERROR");
assert!(grpc_error.is_retryable());
let order_error = TliServiceError::order_validation("ORD123", "quantity", "Must be positive");
assert_eq!(order_error.category(), common::error::ErrorCategory::Validation);
assert!(!order_error.is_retryable());
}
#[test]
fn test_service_specific_errors() {
let trading_error = TliServiceError::trading_service("submit_order", "Service unavailable");
assert_eq!(trading_error.category(), common::error::ErrorCategory::Trading);
assert_eq!(trading_error.severity(), common::error::ErrorSeverity::Error);
assert!(trading_error.is_retryable());
let ml_error = TliServiceError::ml_service("train_model", "GPU memory exhausted");
assert_eq!(ml_error.category(), common::error::ErrorCategory::ML);
assert!(ml_error.is_retryable());
}
#[test]
fn test_retry_strategies() {
let cert_error = TliServiceError::certificate_validation("TLS", "Certificate expired");
assert!(!cert_error.is_retryable());
assert_eq!(cert_error.retry_strategy(), common::error::RetryStrategy::NoRetry);
let grpc_error = TliServiceError::grpc_connection("ml", "localhost:50052", "Connection timeout");
assert!(grpc_error.is_retryable());
match grpc_error.retry_strategy() {
common::error::RetryStrategy::Exponential { base_delay_ms, max_delay_ms } => {
assert_eq!(base_delay_ms, 500);
assert_eq!(max_delay_ms, 5000);
}
_ => panic!("Expected exponential backoff for gRPC connection errors"),
}
}
#[test]
fn test_grpc_status_conversion() {
let order_error = TliServiceError::order_validation("ORD456", "price", "Must be greater than zero");
let status: tonic::Status = order_error.into();
assert_eq!(status.code(), Code::InvalidArgument);
assert!(status.message().contains("ORD456"));
assert!(status.message().contains("price"));
let cert_error = TliServiceError::certificate_validation("client", "Invalid signature");
let cert_status: tonic::Status = cert_error.into();
assert_eq!(cert_status.code(), Code::Unauthenticated);
}
#[test]
fn test_buffer_overflow_error() {
let buffer_error = TliServiceError::event_buffer_overflow("order_events", 10000);
assert_eq!(buffer_error.category(), common::error::ErrorCategory::System);
assert_eq!(buffer_error.severity(), common::error::ErrorSeverity::Warn);
assert!(buffer_error.is_retryable());
let status: tonic::Status = buffer_error.into();
assert_eq!(status.code(), Code::ResourceExhausted);
assert!(status.message().contains("10000"));
}
#[test]
fn test_common_error_integration() {
let config_error = TliServiceError::configuration("Missing gRPC endpoint");
let common_error: common::error::CommonError = config_error.into();
assert_eq!(common_error.category(), common::error::ErrorCategory::Configuration);
assert_eq!(common_error.severity(), common::error::ErrorSeverity::Critical);
assert!(!common_error.is_retryable());
}
#[test]
fn test_error_conversion_chain() {
let status = tonic::Status::deadline_exceeded("Request timeout");
let tli_error: TliServiceError = status.into();
let common_error: common::error::CommonError = tli_error.into();
assert_eq!(common_error.category(), common::error::ErrorCategory::System);
assert!(common_error.is_retryable());
match common_error.retry_strategy() {
common::error::RetryStrategy::Linear { .. } => (),
_ => panic!("Expected linear backoff for timeout errors"),
}
}
}

View File

@@ -1,919 +0,0 @@
//! Event processing and deduplication with aggregation rules
//!
//! This module provides intelligent event processing with:
//! - Event deduplication based on configurable keys
//! - Aggregation rules for time-based windowing
//! - Event enrichment and transformation
//! - Pattern matching and correlation
//! - Statistical aggregation (count, sum, avg, min, max)
//! - Real-time event stream processing
use crate::error::{TliError, TliResult};
use crate::events::{Event, EventFilter, EventSeverity, EventType};
use chrono::{DateTime, Duration as ChronoDuration, Timelike, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::sync::Arc;
use tokio::sync::{mpsc, watch, RwLock};
use tokio::time::{interval, Duration};
use tracing::{debug, error, info, instrument, warn};
/// Configuration for event aggregation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregationConfig {
/// Enable event deduplication
pub enable_deduplication: bool,
/// Deduplication window in seconds
pub dedup_window_seconds: u64,
/// Maximum number of duplicate events to track
pub max_dedup_entries: usize,
/// Enable time-based aggregation
pub enable_time_aggregation: bool,
/// Aggregation window size in seconds
pub aggregation_window_seconds: u64,
/// Enable statistical aggregation
pub enable_statistics: bool,
/// Enable event enrichment
pub enable_enrichment: bool,
/// Enable pattern matching
pub enable_pattern_matching: bool,
/// Maximum aggregation rules
pub max_aggregation_rules: usize,
/// Processing batch size
pub processing_batch_size: usize,
/// Processing interval in milliseconds
pub processing_interval_ms: u64,
}
impl Default for AggregationConfig {
fn default() -> Self {
Self {
enable_deduplication: true,
dedup_window_seconds: 60_u64,
max_dedup_entries: 10000_usize,
enable_time_aggregation: true,
aggregation_window_seconds: 300_u64, // 5 minutes
enable_statistics: true,
enable_enrichment: true,
enable_pattern_matching: true,
max_aggregation_rules: 100_usize,
processing_batch_size: 50_usize,
processing_interval_ms: 100_u64,
}
}
}
/// Aggregation rule definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AggregationRule {
/// Unique rule ID
pub id: String,
/// Rule name
pub name: String,
/// Event filter for matching events
pub filter: EventFilter,
/// Aggregation type
pub aggregation_type: AggregationType,
/// Time window for aggregation
pub window_seconds: u64,
/// Fields to aggregate
pub fields: Vec<String>,
/// Grouping keys
pub group_by: Vec<String>,
/// Minimum events required for aggregation
pub min_events: usize,
/// Maximum events in aggregation
pub max_events: usize,
/// Output event type for aggregated events
pub output_event_type: EventType,
/// Enable rule
pub enabled: bool,
}
/// Types of aggregation operations
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AggregationType {
/// Count events
Count,
/// Sum numeric values
Sum,
/// Calculate average
Average,
/// Find minimum value
Min,
/// Find maximum value
Max,
/// Collect unique values
Unique,
/// First event in window
First,
/// Last event in window
Last,
/// Merge event payloads
Merge,
}
/// Deduplication key for identifying duplicate events
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DeduplicationKey {
/// Event type
pub event_type: String,
/// Source service
pub source: String,
/// Key fields from payload
pub key_fields: Vec<(String, String)>,
}
impl DeduplicationKey {
/// Create deduplication key from event
pub fn from_event(event: &Event, key_fields: &[String]) -> Self {
let mut fields = Vec::new();
for field in key_fields {
if let Some(value) = event.payload.get(field) {
fields.push((field.clone(), value.to_string()));
}
}
Self {
event_type: event.event_type.as_str().to_owned(),
source: event.source.clone(),
key_fields: fields,
}
}
}
/// Aggregation window for time-based processing
#[derive(Debug, Clone)]
struct AggregationWindow {
/// Window start time
start_time: DateTime<Utc>,
/// Window end time
end_time: DateTime<Utc>,
/// Events in this window
events: Vec<Event>,
/// Aggregation result
result: Option<Event>,
/// Processing status
processed: bool,
}
impl AggregationWindow {
fn new(start_time: DateTime<Utc>, window_seconds: u64) -> Self {
let end_time = start_time + ChronoDuration::seconds(window_seconds as i64);
Self {
start_time,
end_time,
events: Vec::new(),
result: None,
processed: false,
}
}
fn add_event(&mut self, event: Event) -> bool {
let event_time = event.timestamp_utc();
if event_time >= self.start_time && event_time < self.end_time {
self.events.push(event);
true
} else {
false
}
}
fn is_complete(&self, current_time: DateTime<Utc>) -> bool {
current_time >= self.end_time
}
}
/// Event pattern for correlation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventPattern {
/// Pattern ID
pub id: String,
/// Pattern name
pub name: String,
/// Sequence of event filters
pub sequence: Vec<EventFilter>,
/// Maximum time between events in seconds
pub max_time_between_seconds: u64,
/// Action to take when pattern matches
pub action: PatternAction,
}
/// Action to take when pattern matches
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PatternAction {
/// Generate a new event
GenerateEvent {
event_type: EventType,
severity: EventSeverity,
payload: serde_json::Value,
},
/// Send alert
SendAlert {
message: String,
severity: EventSeverity,
},
/// Log message
Log { level: String, message: String },
}
/// Main event aggregator
pub struct EventAggregator {
/// Configuration
config: AggregationConfig,
/// Aggregation rules
rules: Arc<RwLock<HashMap<String, AggregationRule>>>,
/// Deduplication cache
dedup_cache: Arc<RwLock<HashMap<DeduplicationKey, DateTime<Utc>>>>,
/// Active aggregation windows
aggregation_windows: Arc<RwLock<HashMap<String, Vec<AggregationWindow>>>>,
/// Event patterns
patterns: Arc<RwLock<HashMap<String, EventPattern>>>,
/// Pattern state tracking
pattern_state: Arc<RwLock<HashMap<String, VecDeque<Event>>>>,
/// Processing queue
processing_queue: Arc<RwLock<VecDeque<Event>>>,
/// Output channel for aggregated events
output_sender: mpsc::UnboundedSender<Event>,
output_receiver: Arc<RwLock<Option<mpsc::UnboundedReceiver<Event>>>>,
/// Shutdown signal
shutdown_sender: watch::Sender<bool>,
shutdown_receiver: watch::Receiver<bool>,
}
impl EventAggregator {
/// Create a new event aggregator
pub fn new(config: AggregationConfig) -> Self {
let (output_sender, output_receiver) = mpsc::unbounded_channel();
let (shutdown_sender, shutdown_receiver) = watch::channel(false);
let aggregator = Self {
config,
rules: Arc::new(RwLock::new(HashMap::new())),
dedup_cache: Arc::new(RwLock::new(HashMap::new())),
aggregation_windows: Arc::new(RwLock::new(HashMap::new())),
patterns: Arc::new(RwLock::new(HashMap::new())),
pattern_state: Arc::new(RwLock::new(HashMap::new())),
processing_queue: Arc::new(RwLock::new(VecDeque::new())),
output_sender,
output_receiver: Arc::new(RwLock::new(Some(output_receiver))),
shutdown_sender,
shutdown_receiver,
};
// Start processing tasks
aggregator.start_processing_tasks();
aggregator
}
/// Process an event through the aggregation pipeline
#[instrument(skip(self, event))]
pub async fn process_event(&self, event: Event) -> TliResult<()> {
// Add to processing queue
{
let mut queue = self.processing_queue.write().await;
queue.push_back(event);
}
Ok(())
}
/// Add aggregation rule
pub async fn add_rule(&self, rule: AggregationRule) -> TliResult<()> {
if !rule.enabled {
return Ok(());
}
let mut rules = self.rules.write().await;
if rules.len() >= self.config.max_aggregation_rules {
return Err(TliError::InvalidRequest(
"Maximum number of aggregation rules reached".to_owned(),
));
}
let rule_id = rule.id.clone();
rules.insert(rule_id.clone(), rule);
info!("Added aggregation rule: {}", rule_id);
Ok(())
}
/// Remove aggregation rule
pub async fn remove_rule(&self, rule_id: &str) -> TliResult<()> {
let mut rules = self.rules.write().await;
if rules.remove(rule_id).is_some() {
info!("Removed aggregation rule: {}", rule_id);
Ok(())
} else {
Err(TliError::NotFound(format!("Rule not found: {}", rule_id)))
}
}
/// Add event pattern
pub async fn add_pattern(&self, pattern: EventPattern) -> TliResult<()> {
let mut patterns = self.patterns.write().await;
let pattern_id = pattern.id.clone();
patterns.insert(pattern_id.clone(), pattern);
info!("Added event pattern: {}", pattern_id);
Ok(())
}
/// Get aggregation output receiver
pub async fn get_output_receiver(&self) -> Option<mpsc::UnboundedReceiver<Event>> {
self.output_receiver.write().await.take()
}
/// Start background processing tasks
fn start_processing_tasks(&self) {
// Start event processing task
let processor = self.clone();
tokio::spawn(async move {
processor.event_processing_loop().await;
});
// Start cleanup task
let cleaner = self.clone();
tokio::spawn(async move {
cleaner.cleanup_loop().await;
});
// Start aggregation window processing
let window_processor = self.clone();
tokio::spawn(async move {
window_processor.window_processing_loop().await;
});
}
/// Main event processing loop
async fn event_processing_loop(&self) {
let mut interval = interval(Duration::from_millis(self.config.processing_interval_ms));
let mut shutdown = self.shutdown_receiver.clone();
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(e) = self.process_queued_events().await {
error!("Event processing error: {}", e);
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Event processing loop shutting down");
break;
}
}
}
}
}
/// Process events from the queue
async fn process_queued_events(&self) -> TliResult<()> {
let mut events_to_process = Vec::new();
// Extract batch of events
{
let mut queue = self.processing_queue.write().await;
let batch_size = self.config.processing_batch_size.min(queue.len());
for _ in 0..batch_size {
if let Some(event) = queue.pop_front() {
events_to_process.push(event);
}
}
}
// Process each event
for event in events_to_process {
// Check for duplicates
if self.config.enable_deduplication {
if self.is_duplicate(&event).await {
continue;
}
self.update_dedup_cache(&event).await;
}
// Process through aggregation rules
if self.config.enable_time_aggregation {
self.process_aggregation_rules(&event).await?;
}
// Check event patterns
if self.config.enable_pattern_matching {
self.check_event_patterns(&event).await?;
}
// Enrich event
if self.config.enable_enrichment {
let enriched_event = self.enrich_event(event).await;
if let Err(e) = self.output_sender.send(enriched_event) {
warn!("Failed to send enriched event: {}", e);
}
} else if let Err(e) = self.output_sender.send(event) {
warn!("Failed to send event: {}", e);
}
}
Ok(())
}
/// Check if event is a duplicate
async fn is_duplicate(&self, event: &Event) -> bool {
let dedup_key = DeduplicationKey::from_event(event, &["id".to_owned()]);
let cache = self.dedup_cache.read().await;
if let Some(last_seen) = cache.get(&dedup_key) {
let window = ChronoDuration::seconds(self.config.dedup_window_seconds as i64);
let current_time = Utc::now();
current_time.signed_duration_since(*last_seen) < window
} else {
false
}
}
/// Update deduplication cache
async fn update_dedup_cache(&self, event: &Event) {
let dedup_key = DeduplicationKey::from_event(event, &["id".to_owned()]);
let mut cache = self.dedup_cache.write().await;
cache.insert(dedup_key, Utc::now());
// Cleanup old entries
if cache.len() > self.config.max_dedup_entries {
let cutoff =
Utc::now() - ChronoDuration::seconds(self.config.dedup_window_seconds as i64);
cache.retain(|_, &mut timestamp| timestamp > cutoff);
}
}
/// Process event through aggregation rules
async fn process_aggregation_rules(&self, event: &Event) -> TliResult<()> {
let rules = self.rules.read().await;
for rule in rules.values() {
if !rule.enabled || !rule.filter.matches(event) {
continue;
}
self.add_event_to_window(rule, event.clone()).await?;
}
Ok(())
}
/// Add event to aggregation window
async fn add_event_to_window(&self, rule: &AggregationRule, event: Event) -> TliResult<()> {
let mut windows = self.aggregation_windows.write().await;
let rule_windows = windows.entry(rule.id.clone()).or_insert_with(Vec::new);
let event_time = event.timestamp_utc();
let window_start = event_time
.with_second(0)
.unwrap_or(event_time)
.with_nanosecond(0)
.unwrap_or(event_time);
// Find or create appropriate window
let mut found_window = false;
for window in rule_windows.iter_mut() {
if window.add_event(event.clone()) {
found_window = true;
break;
}
}
// Create new window if needed
if !found_window {
let mut new_window = AggregationWindow::new(window_start, rule.window_seconds);
new_window.add_event(event);
rule_windows.push(new_window);
}
Ok(())
}
/// Check event patterns for correlation
async fn check_event_patterns(&self, event: &Event) -> TliResult<()> {
let patterns = self.patterns.read().await;
let mut pattern_state = self.pattern_state.write().await;
for pattern in patterns.values() {
// Check if event matches first step in pattern
if let Some(first_filter) = pattern.sequence.first() {
if first_filter.matches(event) {
// Start new pattern sequence
let state_key = format!("{}_{}", pattern.id, event.id);
let mut sequence = VecDeque::new();
sequence.push_back(event.clone());
pattern_state.insert(state_key, sequence);
continue;
}
}
// Check existing pattern sequences
let mut completed_patterns = Vec::new();
for (state_key, sequence) in pattern_state.iter_mut() {
if !state_key.starts_with(&pattern.id) {
continue;
}
let step_index = sequence.len();
if step_index < pattern.sequence.len() {
if let Some(filter) = pattern.sequence.get(step_index) {
if filter.matches(event) {
sequence.push_back(event.clone());
// Check if pattern is complete
if sequence.len() == pattern.sequence.len() {
completed_patterns.push((state_key.clone(), sequence.clone()));
}
}
}
}
}
// Execute actions for completed patterns
for (state_key, sequence) in completed_patterns {
self.execute_pattern_action(pattern, &sequence).await?;
pattern_state.remove(&state_key);
}
}
Ok(())
}
/// Execute pattern action
async fn execute_pattern_action(
&self,
pattern: &EventPattern,
sequence: &VecDeque<Event>,
) -> TliResult<()> {
match &pattern.action {
PatternAction::GenerateEvent {
event_type,
severity,
payload,
} => {
let mut correlation_event = Event::new(
event_type.clone(),
severity.clone(),
"aggregator".to_owned(),
payload.clone(),
);
// Add correlation metadata
correlation_event.add_metadata("pattern_id".to_owned(), pattern.id.clone());
correlation_event.add_metadata("pattern_name".to_owned(), pattern.name.clone());
correlation_event
.add_metadata("sequence_length".to_owned(), sequence.len().to_string());
if let Err(e) = self.output_sender.send(correlation_event) {
warn!("Failed to send pattern event: {}", e);
}
},
PatternAction::SendAlert { message, severity } => {
let alert_event = Event::new(
EventType::System,
severity.clone(),
"aggregator".to_owned(),
serde_json::json!({
"alert": true,
"message": message,
"pattern": pattern.name
}),
);
if let Err(e) = self.output_sender.send(alert_event) {
warn!("Failed to send alert event: {}", e);
}
},
PatternAction::Log { level, message } => match level.as_str() {
"debug" => debug!("Pattern {}: {}", pattern.name, message),
"info" => info!("Pattern {}: {}", pattern.name, message),
"warn" => warn!("Pattern {}: {}", pattern.name, message),
"error" => error!("Pattern {}: {}", pattern.name, message),
_ => info!("Pattern {}: {}", pattern.name, message),
},
}
Ok(())
}
/// Enrich event with additional metadata
async fn enrich_event(&self, mut event: Event) -> Event {
// Add processing timestamp
event.add_metadata("processed_at".to_owned(), Utc::now().to_rfc3339());
// Add aggregator metadata
event.add_metadata("processed_by".to_owned(), "aggregator".to_owned());
event
}
/// Window processing loop
async fn window_processing_loop(&self) {
let mut interval = interval(Duration::from_secs(10)); // Check every 10 seconds
let mut shutdown = self.shutdown_receiver.clone();
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(e) = self.process_completed_windows().await {
error!("Window processing error: {}", e);
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Window processing loop shutting down");
break;
}
}
}
}
}
/// Process completed aggregation windows
async fn process_completed_windows(&self) -> TliResult<()> {
let current_time = Utc::now();
let rules = self.rules.read().await;
let mut windows = self.aggregation_windows.write().await;
for (rule_id, rule_windows) in windows.iter_mut() {
let rule = match rules.get(rule_id) {
Some(rule) => rule,
None => continue,
};
let mut completed_indices = Vec::new();
for (index, window) in rule_windows.iter_mut().enumerate() {
if window.is_complete(current_time) && !window.processed {
if window.events.len() >= rule.min_events {
if let Ok(aggregated_event) = self.aggregate_window(rule, window).await {
window.result = Some(aggregated_event.clone());
if let Err(e) = self.output_sender.send(aggregated_event) {
warn!("Failed to send aggregated event: {}", e);
}
}
}
window.processed = true;
completed_indices.push(index);
}
}
// Remove old completed windows
for &index in completed_indices.iter().rev() {
rule_windows.remove(index);
}
}
Ok(())
}
/// Aggregate events in a window
async fn aggregate_window(
&self,
rule: &AggregationRule,
window: &AggregationWindow,
) -> TliResult<Event> {
if window.events.is_empty() {
return Err(TliError::InvalidRequest("Empty window".to_owned()));
}
let mut payload = serde_json::json!({
"aggregation_type": format!("{:?}", rule.aggregation_type),
"window_start": window.start_time.to_rfc3339(),
"window_end": window.end_time.to_rfc3339(),
"event_count": window.events.len(),
"rule_id": rule.id,
"rule_name": rule.name
});
match rule.aggregation_type {
AggregationType::Count => {
payload["count"] = serde_json::json!(window.events.len());
},
AggregationType::Sum => {
let mut sum = 0.0;
for event in &window.events {
for field in &rule.fields {
if let Some(value) = event.payload.get(field) {
if let Some(num) = value.as_f64() {
sum += num;
}
}
}
}
payload["sum"] = serde_json::json!(sum);
},
AggregationType::Average => {
let mut sum = 0.0;
let mut count = 0;
for event in &window.events {
for field in &rule.fields {
if let Some(value) = event.payload.get(field) {
if let Some(num) = value.as_f64() {
sum += num;
count += 1;
}
}
}
}
payload["average"] = if count > 0 {
serde_json::json!(sum / count as f64)
} else {
serde_json::json!(0.0)
};
},
AggregationType::First => {
if let Some(first_event) = window.events.first() {
payload["first_event"] = first_event.payload.clone();
}
},
AggregationType::Last => {
if let Some(last_event) = window.events.last() {
payload["last_event"] = last_event.payload.clone();
}
},
AggregationType::Merge => {
let mut merged = serde_json::json!({});
for event in &window.events {
if let serde_json::Value::Object(obj) = &event.payload {
for (key, value) in obj {
merged[key] = value.clone();
}
}
}
payload["merged"] = merged;
},
_ => {
// Default aggregation
payload["events"] = serde_json::json!(window.events.len());
},
}
let mut aggregated_event = Event::new(
rule.output_event_type.clone(),
EventSeverity::Info,
"aggregator".to_owned(),
payload,
);
// Add rule metadata
aggregated_event.add_metadata("aggregation_rule".to_owned(), rule.id.clone());
Ok(aggregated_event)
}
/// Cleanup loop for old data
async fn cleanup_loop(&self) {
let mut interval = interval(Duration::from_secs(300)); // 5 minutes
let mut shutdown = self.shutdown_receiver.clone();
loop {
tokio::select! {
_ = interval.tick() => {
self.cleanup_old_data().await;
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Cleanup loop shutting down");
break;
}
}
}
}
}
/// Cleanup old data
async fn cleanup_old_data(&self) {
let cutoff = Utc::now() - ChronoDuration::hours(1);
// Cleanup deduplication cache
{
let mut cache = self.dedup_cache.write().await;
cache.retain(|_, &mut timestamp| timestamp > cutoff);
}
// Cleanup pattern state
{
let mut state = self.pattern_state.write().await;
state.retain(|_, sequence| {
if let Some(first_event) = sequence.front() {
first_event.timestamp_utc() > cutoff
} else {
false
}
});
}
debug!("Completed aggregator cleanup");
}
/// Shutdown the aggregator
pub async fn shutdown(&self) -> TliResult<()> {
info!("Shutting down event aggregator");
if let Err(e) = self.shutdown_sender.send(true) {
warn!("Failed to send shutdown signal: {}", e);
}
// Process remaining events
self.process_queued_events().await?;
self.process_completed_windows().await?;
info!("Event aggregator shutdown complete");
Ok(())
}
}
impl Clone for EventAggregator {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
rules: self.rules.clone(),
dedup_cache: self.dedup_cache.clone(),
aggregation_windows: self.aggregation_windows.clone(),
patterns: self.patterns.clone(),
pattern_state: self.pattern_state.clone(),
processing_queue: self.processing_queue.clone(),
output_sender: self.output_sender.clone(),
output_receiver: self.output_receiver.clone(),
shutdown_sender: self.shutdown_sender.clone(),
shutdown_receiver: self.shutdown_receiver.clone(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[tokio::test]
async fn test_deduplication() {
let config = AggregationConfig::default();
let aggregator = EventAggregator::new(config);
let event1 = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"id": "123"}),
);
let event2 = event1.clone();
// First event should not be duplicate
assert!(!aggregator.is_duplicate(&event1).await);
aggregator.update_dedup_cache(&event1).await;
// Second identical event should be duplicate
assert!(aggregator.is_duplicate(&event2).await);
}
#[test]
fn test_aggregation_window() {
let start_time = Utc::now();
let mut window = AggregationWindow::new(start_time, 60);
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({}),
);
assert!(window.add_event(event));
assert_eq!(window.events.len(), 1);
assert!(!window.is_complete(start_time + ChronoDuration::seconds(30)));
assert!(window.is_complete(start_time + ChronoDuration::seconds(70)));
}
#[test]
fn test_deduplication_key() {
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"order_id": "123", "symbol": "AAPL"}),
);
let key =
DeduplicationKey::from_event(&event, &["order_id".to_owned(), "symbol".to_owned()]);
assert_eq!(key.event_type, "trading");
assert_eq!(key.source, "test");
assert_eq!(key.key_fields.len(), 2);
}
}

View File

@@ -1,895 +0,0 @@
//! Event aggregation and buffering with back-pressure handling
//!
//! This module provides memory-efficient event storage with:
//! - Circular buffer with configurable size limits
//! - Back-pressure handling and flow control
//! - Event TTL and automatic cleanup
//! - Memory usage monitoring and alerts
//! - Batch processing and compression
//! - Priority-based event handling
use crate::error::{TliError, TliResult};
use crate::events::{Event, EventFilter, EventSeverity};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use tokio::sync::{watch, RwLock, Semaphore};
use tokio::time::{interval, Duration, Instant};
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
/// Configuration for event buffer
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventBufferConfig {
/// Maximum number of events to store
pub max_events: usize,
/// Maximum memory usage in bytes
pub max_memory_bytes: usize,
/// Event TTL in seconds (0 = no expiry)
pub default_ttl_seconds: u64,
/// Cleanup interval in seconds
pub cleanup_interval_seconds: u64,
/// Enable compression for stored events
pub enable_compression: bool,
/// Compression threshold in bytes
pub compression_threshold_bytes: usize,
/// Enable back-pressure when buffer is full
pub enable_backpressure: bool,
/// Back-pressure threshold (percentage of `max_events`)
pub backpressure_threshold_percent: f32,
/// Batch size for processing events
pub batch_size: usize,
/// Enable priority queue for critical events
pub enable_priority_queue: bool,
/// Memory warning threshold (percentage of `max_memory_bytes`)
pub memory_warning_threshold_percent: f32,
}
impl Default for EventBufferConfig {
fn default() -> Self {
Self {
max_events: 100_000,
max_memory_bytes: 100 * 1024 * 1024, // 100MB
default_ttl_seconds: 3600, // 1 hour
cleanup_interval_seconds: 60, // 1 minute
enable_compression: true,
compression_threshold_bytes: 1024, // 1KB
enable_backpressure: true,
backpressure_threshold_percent: 0.8, // 80%
batch_size: 100,
enable_priority_queue: true,
memory_warning_threshold_percent: 0.9, // 90%
}
}
}
/// Event buffer metrics for monitoring
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventBufferMetrics {
/// Total events currently stored
pub events_stored: usize,
/// Memory usage in bytes
pub memory_usage_bytes: usize,
/// Events added since start
pub events_added: u64,
/// Events removed since start
pub events_removed: u64,
/// Events expired since start
pub events_expired: u64,
/// Events compressed since start
pub events_compressed: u64,
/// Current back-pressure status
pub backpressure_active: bool,
/// Number of times back-pressure was triggered
pub backpressure_count: u64,
/// Average event size in bytes
pub average_event_size_bytes: f64,
/// Events by type
pub events_by_type: HashMap<String, usize>,
/// Events by severity
pub events_by_severity: HashMap<String, usize>,
/// Last cleanup time
pub last_cleanup_at: Option<DateTime<Utc>>,
/// Buffer utilization percentage
pub utilization_percent: f32,
}
impl Default for EventBufferMetrics {
fn default() -> Self {
Self {
events_stored: 0,
memory_usage_bytes: 0,
events_added: 0,
events_removed: 0,
events_expired: 0,
events_compressed: 0,
backpressure_active: false,
backpressure_count: 0,
average_event_size_bytes: 0.0,
events_by_type: HashMap::new(),
events_by_severity: HashMap::new(),
last_cleanup_at: None,
utilization_percent: 0.0,
}
}
}
/// Stored event with metadata
#[derive(Debug, Clone)]
struct StoredEvent {
/// The event data
event: Event,
/// Size in bytes
size_bytes: usize,
/// Compressed payload (if compression enabled)
#[allow(dead_code)]
compressed_payload: Option<Vec<u8>>,
/// Insert timestamp
#[allow(dead_code)]
inserted_at: Instant,
}
impl StoredEvent {
fn new(event: Event) -> Self {
let size_bytes = Self::calculate_size(&event);
Self {
event,
size_bytes,
compressed_payload: None,
inserted_at: Instant::now(),
}
}
fn calculate_size(event: &Event) -> usize {
// Rough estimation of event size in memory
size_of::<Event>()
+ event.source.len()
+ event.payload.to_string().len()
+ event
.metadata
.iter()
.map(|(k, v)| k.len() + v.len())
.sum::<usize>()
}
#[allow(dead_code)]
fn compress(&mut self) -> TliResult<()> {
if self.compressed_payload.is_some() {
return Ok(()); // Already compressed
}
let payload_str = self.event.payload.to_string();
if payload_str.len() < 1024 {
return Ok(()); // Too small to compress
}
// Simple compression using flate2 (would need to add dependency)
// For now, just store as-is
self.compressed_payload = Some(payload_str.into_bytes());
Ok(())
}
}
/// Priority level for events
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum EventPriority {
Low = 0_isize,
Normal = 1_isize,
High = 2_isize,
Critical = 3_isize,
}
impl From<EventSeverity> for EventPriority {
fn from(severity: EventSeverity) -> Self {
match severity {
EventSeverity::Info => EventPriority::Low,
EventSeverity::Warning => EventPriority::Normal,
EventSeverity::Error => EventPriority::High,
EventSeverity::Critical => EventPriority::Critical,
}
}
}
/// Event buffer that manages memory-efficient event storage
pub struct EventBuffer {
/// Configuration
config: EventBufferConfig,
/// Main event storage (circular buffer)
events: Arc<RwLock<VecDeque<StoredEvent>>>,
/// Priority queue for critical events
priority_events: Arc<RwLock<VecDeque<StoredEvent>>>,
/// Event index for fast lookups
event_index: Arc<RwLock<HashMap<Uuid, usize>>>,
/// Buffer metrics
metrics: Arc<RwLock<EventBufferMetrics>>,
/// Back-pressure semaphore
backpressure_semaphore: Arc<Semaphore>,
/// Shutdown signal
shutdown_sender: watch::Sender<bool>,
shutdown_receiver: watch::Receiver<bool>,
}
impl EventBuffer {
/// Create a new event buffer
pub fn new(config: EventBufferConfig) -> Self {
let backpressure_permits =
(config.max_events as f32 * config.backpressure_threshold_percent) as usize;
let backpressure_semaphore = Arc::new(Semaphore::new(backpressure_permits));
let (shutdown_sender, shutdown_receiver) = watch::channel(false);
let buffer = Self {
config,
events: Arc::new(RwLock::new(VecDeque::new())),
priority_events: Arc::new(RwLock::new(VecDeque::new())),
event_index: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(RwLock::new(EventBufferMetrics::default())),
backpressure_semaphore,
shutdown_sender,
shutdown_receiver,
};
// Start cleanup task
buffer.start_cleanup_task();
buffer
}
/// Add an event to the buffer
#[instrument(skip(self, event))]
pub async fn add_event(&self, event: Event) -> TliResult<()> {
// Check back-pressure
if self.config.enable_backpressure {
let permit = self.backpressure_semaphore.try_acquire().map_err(|_| {
// Update back-pressure metrics
tokio::spawn({
let metrics = self.metrics.clone();
async move {
let mut m = metrics.write().await;
m.backpressure_active = true;
m.backpressure_count += 1;
}
});
TliError::BufferFull("Event buffer back-pressure active".to_owned())
})?;
// Release permit after processing
std::mem::forget(permit);
}
let stored_event = StoredEvent::new(event.clone());
let event_id = event.id;
let priority = EventPriority::from(event.severity.clone());
// Determine which queue to use
let use_priority_queue = self.config.enable_priority_queue
&& (priority == EventPriority::Critical || priority == EventPriority::High);
if use_priority_queue {
// Add to priority queue
let mut priority_events = self.priority_events.write().await;
priority_events.push_back(stored_event);
// Ensure priority queue doesn't grow too large
let max_priority_events = self.config.max_events / 10; // 10% of total
while priority_events.len() > max_priority_events {
if let Some(removed) = priority_events.pop_front() {
self.update_metrics_on_removal(&removed.event).await;
}
}
} else {
// Add to main buffer
let mut events = self.events.write().await;
let mut index = self.event_index.write().await;
// Check if buffer is full
if events.len() >= self.config.max_events {
// Remove oldest event
if let Some(removed) = events.pop_front() {
index.remove(&removed.event.id);
self.update_metrics_on_removal(&removed.event).await;
}
}
// Add new event
let position = events.len();
events.push_back(stored_event);
index.insert(event_id, position);
}
// Update metrics
self.update_metrics_on_addition(&event).await;
// Check memory usage
self.check_memory_usage().await;
Ok(())
}
/// Get events matching a filter
pub async fn get_events(&self, filter: &EventFilter, limit: Option<usize>) -> Vec<Event> {
let mut result = Vec::new();
let max_results = limit.unwrap_or(1000);
// Check priority events first
if self.config.enable_priority_queue {
let priority_events = self.priority_events.read().await;
for stored_event in priority_events.iter().rev() {
// Most recent first
if result.len() >= max_results {
break;
}
if !stored_event.event.is_expired() && filter.matches(&stored_event.event) {
result.push(stored_event.event.clone());
}
}
}
// Check main buffer
if result.len() < max_results {
let events = self.events.read().await;
for stored_event in events.iter().rev() {
// Most recent first
if result.len() >= max_results {
break;
}
if !stored_event.event.is_expired() && filter.matches(&stored_event.event) {
result.push(stored_event.event.clone());
}
}
}
result
}
/// Get event by ID
pub async fn get_event_by_id(&self, id: &Uuid) -> Option<Event> {
// Check priority events first
if self.config.enable_priority_queue {
let priority_events = self.priority_events.read().await;
for stored_event in priority_events.iter() {
if stored_event.event.id == *id && !stored_event.event.is_expired() {
return Some(stored_event.event.clone());
}
}
}
// Check main buffer
let index = self.event_index.read().await;
if let Some(&position) = index.get(id) {
let events = self.events.read().await;
if let Some(stored_event) = events.get(position) {
if !stored_event.event.is_expired() {
return Some(stored_event.event.clone());
}
}
}
None
}
/// Get events in a time range
pub async fn get_events_in_range(
&self,
start_time_nanos: i64,
end_time_nanos: i64,
limit: Option<usize>,
) -> Vec<Event> {
let filter = EventFilter {
start_time_nanos: Some(start_time_nanos),
end_time_nanos: Some(end_time_nanos),
..EventFilter::all()
};
self.get_events(&filter, limit).await
}
/// Get buffer metrics
pub async fn get_metrics(&self) -> EventBufferMetrics {
self.metrics.read().await.clone()
}
/// Manually trigger cleanup
pub async fn cleanup(&self) -> TliResult<()> {
let mut expired_count = 0;
let mut memory_freed = 0;
// Clean priority events
if self.config.enable_priority_queue {
let mut priority_events = self.priority_events.write().await;
let original_len = priority_events.len();
priority_events.retain(|stored_event| {
let expired = stored_event.event.is_expired();
if expired {
memory_freed += stored_event.size_bytes;
}
!expired
});
expired_count += original_len - priority_events.len();
}
// Clean main buffer
{
let mut events = self.events.write().await;
let mut index = self.event_index.write().await;
let original_len = events.len();
let mut retained_events = VecDeque::new();
let mut new_index = HashMap::new();
for stored_event in events.drain(..) {
if !stored_event.event.is_expired() {
let new_pos = retained_events.len();
let event_id = stored_event.event.id;
retained_events.push_back(stored_event);
new_index.insert(event_id, new_pos);
} else {
memory_freed += stored_event.size_bytes;
}
}
*events = retained_events;
*index = new_index;
expired_count += original_len - events.len();
}
// Update metrics
{
let mut metrics = self.metrics.write().await;
metrics.events_expired += expired_count as u64;
metrics.memory_usage_bytes = metrics.memory_usage_bytes.saturating_sub(memory_freed);
metrics.last_cleanup_at = Some(Utc::now());
metrics.events_stored = metrics.events_stored.saturating_sub(expired_count);
// Update utilization
metrics.utilization_percent =
(metrics.events_stored as f32 / self.config.max_events as f32) * 100.0;
}
if expired_count > 0 {
debug!(
"Cleaned up {} expired events, freed {} bytes",
expired_count, memory_freed
);
}
Ok(())
}
/// Clear all events from buffer
pub async fn clear(&self) -> TliResult<()> {
{
let mut events = self.events.write().await;
let mut priority_events = self.priority_events.write().await;
let mut index = self.event_index.write().await;
events.clear();
priority_events.clear();
index.clear();
}
// Reset metrics
{
let mut metrics = self.metrics.write().await;
*metrics = EventBufferMetrics::default();
}
info!("Event buffer cleared");
Ok(())
}
/// Start the cleanup task
fn start_cleanup_task(&self) {
let buffer = self.clone();
tokio::spawn(async move {
let mut interval =
interval(Duration::from_secs(buffer.config.cleanup_interval_seconds));
let mut shutdown = buffer.shutdown_receiver.clone();
loop {
tokio::select! {
_ = interval.tick() => {
if let Err(e) = buffer.cleanup().await {
error!("Cleanup task error: {}", e);
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Cleanup task shutting down");
break;
}
}
}
}
});
}
/// Update metrics when adding an event
async fn update_metrics_on_addition(&self, event: &Event) {
let mut metrics = self.metrics.write().await;
metrics.events_added += 1;
metrics.events_stored += 1;
let event_size = StoredEvent::calculate_size(event);
metrics.memory_usage_bytes += event_size;
// Update average size
metrics.average_event_size_bytes = (metrics.average_event_size_bytes
* (metrics.events_added - 1) as f64
+ event_size as f64)
/ metrics.events_added as f64;
// Update type counts
let type_key = event.event_type.as_str().to_owned();
*metrics.events_by_type.entry(type_key).or_insert(0) += 1;
// Update severity counts
let severity_key = match event.severity {
EventSeverity::Info => "info",
EventSeverity::Warning => "warning",
EventSeverity::Error => "error",
EventSeverity::Critical => "critical",
}
.to_owned();
*metrics.events_by_severity.entry(severity_key).or_insert(0) += 1;
// Update utilization
metrics.utilization_percent =
(metrics.events_stored as f32 / self.config.max_events as f32) * 100.0;
// Reset back-pressure if no longer needed
if metrics.backpressure_active && metrics.utilization_percent < 70.0 {
metrics.backpressure_active = false;
}
}
/// Update metrics when removing an event
async fn update_metrics_on_removal(&self, event: &Event) {
let mut metrics = self.metrics.write().await;
metrics.events_removed += 1;
metrics.events_stored = metrics.events_stored.saturating_sub(1);
let event_size = StoredEvent::calculate_size(event);
metrics.memory_usage_bytes = metrics.memory_usage_bytes.saturating_sub(event_size);
// Update type counts
let type_key = event.event_type.as_str().to_owned();
if let Some(count) = metrics.events_by_type.get_mut(&type_key) {
*count = count.saturating_sub(1);
}
// Update severity counts
let severity_key = match event.severity {
EventSeverity::Info => "info",
EventSeverity::Warning => "warning",
EventSeverity::Error => "error",
EventSeverity::Critical => "critical",
}
.to_owned();
if let Some(count) = metrics.events_by_severity.get_mut(&severity_key) {
*count = count.saturating_sub(1);
}
// Update utilization
metrics.utilization_percent =
(metrics.events_stored as f32 / self.config.max_events as f32) * 100.0;
}
/// Check memory usage and trigger warnings
async fn check_memory_usage(&self) {
let metrics = self.metrics.read().await;
let usage_percent =
(metrics.memory_usage_bytes as f32 / self.config.max_memory_bytes as f32) * 100.0;
if usage_percent > self.config.memory_warning_threshold_percent * 100.0 {
warn!(
"Event buffer memory usage high: {:.1}% ({} bytes)",
usage_percent, metrics.memory_usage_bytes
);
}
}
/// Shutdown the buffer
pub async fn shutdown(&self) -> TliResult<()> {
info!("Shutting down event buffer");
if let Err(e) = self.shutdown_sender.send(true) {
warn!("Failed to send shutdown signal: {}", e);
}
// Final cleanup
self.cleanup().await?;
info!("Event buffer shutdown complete");
Ok(())
}
}
impl Clone for EventBuffer {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
events: self.events.clone(),
priority_events: self.priority_events.clone(),
event_index: self.event_index.clone(),
metrics: self.metrics.clone(),
backpressure_semaphore: self.backpressure_semaphore.clone(),
shutdown_sender: self.shutdown_sender.clone(),
shutdown_receiver: self.shutdown_receiver.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::{Event, EventFilter, EventSeverity, EventType};
#[tokio::test]
async fn test_event_buffer_basic_operations() {
let config = EventBufferConfig {
max_events: 10,
..EventBufferConfig::default()
};
let buffer = EventBuffer::new(config);
// Add some events
for i in 0..5 {
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
buffer.add_event(event).await.unwrap();
}
// Check metrics
let metrics = buffer.get_metrics().await;
assert_eq!(metrics.events_stored, 5);
assert_eq!(metrics.events_added, 5);
// Get all events
let events = buffer.get_events(&EventFilter::all(), None).await;
assert_eq!(events.len(), 5);
}
#[tokio::test]
async fn test_event_buffer_overflow() {
let config = EventBufferConfig {
max_events: 3,
enable_backpressure: false, // Disable backpressure for overflow test
..EventBufferConfig::default()
};
let buffer = EventBuffer::new(config);
// Add more events than the limit
for i in 0..5 {
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
buffer.add_event(event).await.unwrap();
}
// Should only have max_events
let metrics = buffer.get_metrics().await;
assert_eq!(metrics.events_stored, 3);
assert_eq!(metrics.events_added, 5);
assert_eq!(metrics.events_removed, 2);
}
#[tokio::test]
async fn test_event_filter() {
let buffer = EventBuffer::new(EventBufferConfig::default());
// Add events of different types
let trading_event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({}),
);
let market_event = Event::new(
EventType::MarketData,
EventSeverity::Warning,
"test".to_owned(),
serde_json::json!({}),
);
buffer.add_event(trading_event).await.unwrap();
buffer.add_event(market_event).await.unwrap();
// Filter by type
let trading_filter = EventFilter::for_types(vec![EventType::Trading]);
let trading_events = buffer.get_events(&trading_filter, None).await;
assert_eq!(trading_events.len(), 1);
// Filter by severity
let warning_filter = EventFilter::with_min_severity(EventSeverity::Warning);
let warning_events = buffer.get_events(&warning_filter, None).await;
assert_eq!(warning_events.len(), 1);
}
#[tokio::test]
async fn test_event_buffer_priority_queue() {
let config = EventBufferConfig {
max_events: 10,
enable_priority_queue: true,
..EventBufferConfig::default()
};
let buffer = EventBuffer::new(config);
// Add critical event
let critical_event = Event::new(
EventType::System,
EventSeverity::Critical,
"test".to_owned(),
serde_json::json!({"message": "critical"}),
);
// Add normal events
let normal_event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"message": "normal"}),
);
buffer.add_event(normal_event).await.unwrap();
buffer.add_event(critical_event.clone()).await.unwrap();
// Priority events should be retrievable
let all_events = buffer.get_events(&EventFilter::all(), None).await;
assert_eq!(all_events.len(), 2);
}
#[tokio::test]
async fn test_event_buffer_cleanup() {
let config = EventBufferConfig {
max_events: 10,
cleanup_interval_seconds: 1,
..EventBufferConfig::default()
};
let buffer = EventBuffer::new(config);
// Add events
for i in 0..3 {
let mut event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
// Set very short TTL for testing
event.set_ttl(1); // 1 second TTL
buffer.add_event(event).await.unwrap();
}
let metrics_before = buffer.get_metrics().await;
assert_eq!(metrics_before.events_stored, 3);
// Wait for events to expire
tokio::time::sleep(Duration::from_secs(2)).await;
// Manually trigger cleanup
buffer.cleanup().await.unwrap();
let metrics_after = buffer.get_metrics().await;
assert_eq!(metrics_after.events_stored, 0);
assert_eq!(metrics_after.events_expired, 3);
}
#[tokio::test]
async fn test_event_buffer_by_id() {
let buffer = EventBuffer::new(EventBufferConfig::default());
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"test": "data"}),
);
let event_id = event.id;
buffer.add_event(event).await.unwrap();
// Get by ID
let retrieved = buffer.get_event_by_id(&event_id).await;
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().id, event_id);
// Non-existent ID
let non_existent = buffer.get_event_by_id(&Uuid::new_v4()).await;
assert!(non_existent.is_none());
}
#[tokio::test]
async fn test_event_buffer_time_range() {
let buffer = EventBuffer::new(EventBufferConfig::default());
let start_time = crate::types::current_unix_nanos();
// Add events with small delay
for i in 0..3 {
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
buffer.add_event(event).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
}
let end_time = crate::types::current_unix_nanos();
// Get events in range
let events = buffer.get_events_in_range(start_time, end_time, None).await;
assert_eq!(events.len(), 3);
}
#[tokio::test]
async fn test_event_buffer_clear() {
let buffer = EventBuffer::new(EventBufferConfig::default());
// Add events
for i in 0..5 {
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
buffer.add_event(event).await.unwrap();
}
let metrics_before = buffer.get_metrics().await;
assert_eq!(metrics_before.events_stored, 5);
// Clear buffer
buffer.clear().await.unwrap();
let metrics_after = buffer.get_metrics().await;
assert_eq!(metrics_after.events_stored, 0);
assert_eq!(metrics_after.events_added, 0);
}
#[tokio::test]
async fn test_event_buffer_backpressure() {
let config = EventBufferConfig {
max_events: 10,
enable_backpressure: true,
backpressure_threshold_percent: 0.5, // 50%
..EventBufferConfig::default()
};
let buffer = EventBuffer::new(config);
// Fill buffer to trigger backpressure
for i in 0..6 {
// More than 50% of max
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test".to_owned(),
serde_json::json!({"index": i}),
);
let result = buffer.add_event(event).await;
// First 5 should succeed, 6th might fail due to backpressure
if i < 5 {
result.unwrap();
}
}
let metrics = buffer.get_metrics().await;
assert!(metrics.events_stored >= 5);
}
}

View File

@@ -1,603 +0,0 @@
//! Real-time event streaming system for TLI
//!
//! This module provides comprehensive event handling for live data including:
//! - gRPC streaming client management with automatic reconnection
//! - Event aggregation and buffering with back-pressure handling
//! - Event replay capabilities for historical analysis
//! - WebSocket support for browser clients
//! - Memory-efficient event storage and deduplication
//! - Performance metrics and monitoring
//!
//! Architecture:
//! ```text
//! gRPC Services → StreamManager → EventBuffer → Aggregator → [WebSocket|Replay]
//! ↓ ↓ ↓
//! Reconnection Back-pressure Deduplication
//! Exponential Memory Mgmt Ordering
//! Backoff Flow Control Metrics
//! ```
pub mod aggregator;
pub mod event_buffer;
pub mod stream_manager;
// pub mod replay_system; // Disabled - client should not have database dependencies
// websocket_server module removed - TLI is pure client
// NO RE-EXPORTS: Import directly from submodules
// Use tli::events::aggregator::{EventAggregator, AggregationConfig} instead
// Use tli::events::event_buffer::{EventBuffer, EventBufferConfig} instead
// Use tli::events::stream_manager::{StreamManager, StreamConfig} instead
use crate::error::TliResult;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc, RwLock};
// BroadcastStream is now available with tokio-stream sync feature enabled
use tracing::{debug, error, info, warn};
use uuid::Uuid;
// Re-export main components for convenience
// These are commonly needed types that examples and client code use frequently
pub use aggregator::{
AggregationConfig, AggregationRule, AggregationType, EventAggregator, EventPattern,
PatternAction,
};
pub use event_buffer::{EventBuffer, EventBufferConfig, EventBufferMetrics};
pub use stream_manager::{StreamConfig, StreamConnection, StreamHealth, StreamManager};
// pub use replay_system::{ReplaySystem, ReplayConfig, ReplayFilter}; // Disabled
// WebSocketServer removed - TLI is pure client, no server components
/// Event types supported by the streaming system
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EventType {
/// Market data events (quotes, trades, order book)
MarketData,
/// Trading events (orders, executions, positions)
Trading,
/// Risk management events (limits, breaches, alerts)
Risk,
/// ML signals and predictions
MlSignal,
/// System health and monitoring
System,
/// Configuration changes
Config,
/// Custom user-defined events
Custom(String),
}
impl EventType {
/// Convert to string for serialization
pub fn as_str(&self) -> &str {
match self {
EventType::MarketData => "market_data",
EventType::Trading => "trading",
EventType::Risk => "risk",
EventType::MlSignal => "ml_signal",
EventType::System => "system",
EventType::Config => "config",
EventType::Custom(name) => name,
}
}
/// Parse from string
pub fn from_str(s: &str) -> Self {
match s {
"market_data" => EventType::MarketData,
"trading" => EventType::Trading,
"risk" => EventType::Risk,
"ml_signal" => EventType::MlSignal,
"system" => EventType::System,
"config" => EventType::Config,
name => EventType::Custom(name.to_owned()),
}
}
}
/// Event severity levels for filtering and prioritization
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum EventSeverity {
/// Low priority informational events
Info,
/// Warning events that may require attention
Warning,
/// Error events that require immediate attention
Error,
/// Critical events that require urgent action
Critical,
}
/// Core event structure for all streaming data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
/// Unique event identifier
pub id: Uuid,
/// Event type classification
pub event_type: EventType,
/// Event severity level
pub severity: EventSeverity,
/// Source service that generated the event
pub source: String,
/// Event timestamp (nanoseconds since Unix epoch)
pub timestamp_nanos: i64,
/// Sequence number for ordering within source
pub sequence: u64,
/// Event payload as JSON value
pub payload: serde_json::Value,
/// Optional correlation ID for related events
pub correlation_id: Option<Uuid>,
/// Event metadata and labels
pub metadata: HashMap<String, String>,
/// TTL in seconds (0 = no expiry)
pub ttl_seconds: u64,
}
impl Event {
/// Create a new event with required fields
pub fn new(
event_type: EventType,
severity: EventSeverity,
source: String,
payload: serde_json::Value,
) -> Self {
Self {
id: Uuid::new_v4(),
event_type,
severity,
source,
timestamp_nanos: crate::types::current_unix_nanos(),
sequence: 0_u64, // Set by stream manager
payload,
correlation_id: None,
metadata: HashMap::new(),
ttl_seconds: 3600_u64, // 1 hour default TTL
}
}
/// Create a new event with correlation ID
pub fn with_correlation(
event_type: EventType,
severity: EventSeverity,
source: String,
payload: serde_json::Value,
correlation_id: Uuid,
) -> Self {
let mut event = Self::new(event_type, severity, source, payload);
event.correlation_id = Some(correlation_id);
event
}
/// Set sequence number (called by stream manager)
pub fn set_sequence(&mut self, sequence: u64) {
self.sequence = sequence;
}
/// Add metadata label
pub fn add_metadata(&mut self, key: String, value: String) {
self.metadata.insert(key, value);
}
/// Set TTL in seconds
pub fn set_ttl(&mut self, ttl_seconds: u64) {
self.ttl_seconds = ttl_seconds;
}
/// Check if event has expired
pub fn is_expired(&self) -> bool {
if self.ttl_seconds == 0 {
return false; // No expiry
}
let current_nanos = crate::types::current_unix_nanos();
let expiry_nanos = self.timestamp_nanos + (self.ttl_seconds as i64 * 1_000_000_000);
current_nanos > expiry_nanos
}
/// Get event age in milliseconds
pub fn age_millis(&self) -> i64 {
let current_nanos = crate::types::current_unix_nanos();
(current_nanos - self.timestamp_nanos) / 1_000_000
}
/// Convert to `DateTime` for display
pub fn timestamp_utc(&self) -> DateTime<Utc> {
let secs = self.timestamp_nanos / 1_000_000_000;
let nanos = (self.timestamp_nanos % 1_000_000_000) as u32;
DateTime::from_timestamp(secs, nanos).unwrap_or_else(Utc::now)
}
}
/// Event stream subscription filter
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventFilter {
/// Event types to include (empty = all types)
pub event_types: Vec<EventType>,
/// Minimum severity level
pub min_severity: EventSeverity,
/// Source services to include (empty = all sources)
pub sources: Vec<String>,
/// Metadata filters (key-value pairs that must match)
pub metadata_filters: HashMap<String, String>,
/// Correlation ID filter
pub correlation_id: Option<Uuid>,
/// Time range filter (start timestamp in nanos)
pub start_time_nanos: Option<i64>,
/// Time range filter (end timestamp in nanos)
pub end_time_nanos: Option<i64>,
}
impl EventFilter {
/// Create a filter for all events
pub fn all() -> Self {
Self {
event_types: Vec::new(),
min_severity: EventSeverity::Info,
sources: Vec::new(),
metadata_filters: HashMap::new(),
correlation_id: None,
start_time_nanos: None,
end_time_nanos: None,
}
}
/// Create a filter for specific event types
pub fn for_types(event_types: Vec<EventType>) -> Self {
Self {
event_types,
..Self::all()
}
}
/// Create a filter for specific sources
pub fn for_sources(sources: Vec<String>) -> Self {
Self {
sources,
..Self::all()
}
}
/// Create a filter for minimum severity
pub fn with_min_severity(min_severity: EventSeverity) -> Self {
Self {
min_severity,
..Self::all()
}
}
/// Check if event matches this filter
pub fn matches(&self, event: &Event) -> bool {
// Check event types
if !self.event_types.is_empty() && !self.event_types.contains(&event.event_type) {
return false;
}
// Check severity
if event.severity < self.min_severity {
return false;
}
// Check sources
if !self.sources.is_empty() && !self.sources.contains(&event.source) {
return false;
}
// Check correlation ID
if let Some(filter_correlation_id) = &self.correlation_id {
if event.correlation_id.as_ref() != Some(filter_correlation_id) {
return false;
}
}
// Check metadata filters
for (key, value) in &self.metadata_filters {
if event.metadata.get(key) != Some(value) {
return false;
}
}
// Check time range
if let Some(start_time) = self.start_time_nanos {
if event.timestamp_nanos < start_time {
return false;
}
}
if let Some(end_time) = self.end_time_nanos {
if event.timestamp_nanos > end_time {
return false;
}
}
true
}
}
/// Event subscription handle for managing live event streams
pub struct EventSubscription {
/// Subscription ID
pub id: Uuid,
/// Event filter
pub filter: EventFilter,
/// Event receiver
pub receiver: mpsc::UnboundedReceiver<Event>,
/// Subscription metadata
pub metadata: HashMap<String, String>,
}
impl EventSubscription {
/// Create a new subscription
pub fn new(filter: EventFilter, receiver: mpsc::UnboundedReceiver<Event>) -> Self {
Self {
id: Uuid::new_v4(),
filter,
receiver,
metadata: HashMap::new(),
}
}
/// Add subscription metadata
pub fn add_metadata(&mut self, key: String, value: String) {
self.metadata.insert(key, value);
}
}
/// Core event streaming system that coordinates all components
pub struct EventStreamingSystem {
/// Stream manager for gRPC connections
stream_manager: Arc<StreamManager>,
/// Event buffer for aggregation and storage
event_buffer: Arc<EventBuffer>,
/// Event aggregator for processing
aggregator: Arc<EventAggregator>,
// replay_system: Arc<ReplaySystem>, // Disabled - client should not have database dependencies
/// WebSocket server removed - TLI is pure client, no server components
///
/// Event broadcast channel for live subscriptions
_event_sender: broadcast::Sender<Event>,
/// System shutdown signal
shutdown_sender: tokio::sync::watch::Sender<bool>,
shutdown_receiver: tokio::sync::watch::Receiver<bool>,
/// System metrics
metrics: Arc<RwLock<EventSystemMetrics>>,
}
/// System-wide event streaming metrics
#[derive(Debug, Default, Clone)]
pub struct EventSystemMetrics {
/// Total events processed
pub events_processed: u64,
/// Events processed per second
pub events_per_second: f64,
/// Total active subscriptions
pub active_subscriptions: u64,
/// Stream connection health
pub stream_health: HashMap<String, bool>,
/// Memory usage in bytes
pub memory_usage_bytes: u64,
/// Last update timestamp
pub last_updated: DateTime<Utc>,
}
impl EventStreamingSystem {
/// Create a new event streaming system
pub async fn new(
stream_config: StreamConfig,
buffer_config: EventBufferConfig,
aggregation_config: AggregationConfig,
// replay_config: ReplayConfig, // Disabled
// websocket_config removed - TLI is pure client
) -> TliResult<Self> {
info!("Initializing event streaming system");
// Create broadcast channel for live events
let (_event_sender, _) = broadcast::channel(10000);
// Create shutdown channel
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
// Initialize components
let stream_manager = Arc::new(StreamManager::new(stream_config).await?);
let event_buffer = Arc::new(EventBuffer::new(buffer_config));
let aggregator = Arc::new(EventAggregator::new(aggregation_config));
// let replay_system = Arc::new(ReplaySystem::new(replay_config).await?); // Disabled
// WebSocket server initialization removed - TLI is pure client
let metrics = Arc::new(RwLock::new(EventSystemMetrics::default()));
Ok(Self {
stream_manager,
event_buffer,
aggregator,
// replay_system, // Disabled
// websocket_server removed - TLI is pure client
_event_sender,
shutdown_sender,
shutdown_receiver,
metrics,
})
}
/// Start the event streaming system
pub async fn start(&self) -> TliResult<()> {
info!("Starting event streaming system");
// Start stream manager
let stream_manager = self.stream_manager.clone();
let _event_sender = self._event_sender.clone();
let shutdown_receiver = self.shutdown_receiver.clone();
tokio::spawn(async move {
if let Err(e) = stream_manager.start(_event_sender, shutdown_receiver).await {
error!("Stream manager error: {}", e);
}
});
// Start event buffer processing
let buffer = self.event_buffer.clone();
let aggregator = self.aggregator.clone();
let mut event_receiver = self._event_sender.subscribe();
let shutdown_receiver = self.shutdown_receiver.clone();
tokio::spawn(async move {
let mut shutdown = shutdown_receiver.clone();
loop {
tokio::select! {
event_result = event_receiver.recv() => {
match event_result {
Ok(event) => {
if let Err(e) = buffer.add_event(event.clone()).await {
error!("Failed to add event to buffer: {}", e);
continue;
}
if let Err(e) = aggregator.process_event(event).await {
error!("Failed to process event in aggregator: {}", e);
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
warn!("Event receiver lagged, skipped {} events", skipped);
}
Err(broadcast::error::RecvError::Closed) => {
debug!("Event receiver closed");
break;
}
}
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Event buffer processing shutdown");
break;
}
}
}
}
});
// WebSocket server startup removed - TLI is pure client, no server components
// Start metrics collection
let metrics = self.metrics.clone();
let shutdown_receiver = self.shutdown_receiver.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
let mut shutdown = shutdown_receiver.clone();
loop {
tokio::select! {
_ = interval.tick() => {
let mut metrics_guard = metrics.write().await;
metrics_guard.last_updated = Utc::now();
// Update other metrics here
}
_ = shutdown.changed() => {
if *shutdown.borrow() {
debug!("Metrics collection shutdown");
break;
}
}
}
}
});
info!("Event streaming system started successfully");
Ok(())
}
/// Subscribe to events with a filter
pub async fn subscribe(&self, filter: EventFilter) -> TliResult<EventSubscription> {
let (sender, receiver) = mpsc::unbounded_channel();
let mut event_receiver = self._event_sender.subscribe();
let filter_clone = filter.clone();
tokio::spawn(async move {
while let Ok(event) = event_receiver.recv().await {
if filter_clone.matches(&event) && sender.send(event).is_err() {
debug!("Event subscription receiver dropped");
break;
}
}
});
// Update subscription count
{
let mut metrics = self.metrics.write().await;
metrics.active_subscriptions += 1;
}
Ok(EventSubscription::new(filter, receiver))
}
/// Get system metrics
pub async fn get_metrics(&self) -> EventSystemMetrics {
(*self.metrics.read().await).clone()
}
/// Shutdown the event streaming system
pub async fn shutdown(&self) -> TliResult<()> {
info!("Shutting down event streaming system");
if let Err(e) = self.shutdown_sender.send(true) {
warn!("Failed to send shutdown signal: {}", e);
}
// Give components time to shutdown gracefully
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
info!("Event streaming system shutdown complete");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_creation() {
let payload = serde_json::json!({"test": "data"});
let event = Event::new(
EventType::Trading,
EventSeverity::Info,
"test_service".to_owned(),
payload,
);
assert_eq!(event.event_type, EventType::Trading);
assert_eq!(event.severity, EventSeverity::Info);
assert_eq!(event.source, "test_service");
assert!(!event.is_expired());
}
#[test]
fn test_event_filter() {
let filter = EventFilter::for_types(vec![EventType::Trading]);
let trading_event = Event::new(
EventType::Trading,
EventSeverity::Info,
"service".to_owned(),
serde_json::json!({}),
);
let market_event = Event::new(
EventType::MarketData,
EventSeverity::Info,
"service".to_owned(),
serde_json::json!({}),
);
assert!(filter.matches(&trading_event));
assert!(!filter.matches(&market_event));
}
#[test]
fn test_event_severity_ordering() {
assert!(EventSeverity::Critical > EventSeverity::Error);
assert!(EventSeverity::Error > EventSeverity::Warning);
assert!(EventSeverity::Warning > EventSeverity::Info);
}
}

View File

@@ -1,867 +0,0 @@
//! gRPC streaming client management with automatic reconnection
//!
//! This module handles multiple concurrent gRPC streams with:
//! - Automatic reconnection with exponential backoff
//! - Stream health monitoring and metrics
//! - Back-pressure handling and flow control
//! - Connection pooling and load balancing
//! - Circuit breaker pattern for failed connections
use crate::client::ServiceEndpoints;
use crate::error::{TliError, TliResult};
use crate::events::{Event, EventSeverity, EventType};
use crate::proto::trading::{
trading_service_client::TradingServiceClient, MetricsEvent, SubscribeMetricsRequest,
SubscribeSystemStatusRequest, SystemStatusEvent,
};
use chrono::{DateTime, Utc};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{broadcast, RwLock, Semaphore};
use tonic::transport::{Channel, Endpoint};
use tonic::{Request, Streaming};
use tracing::{error, info, instrument, warn};
use uuid::Uuid;
/// Configuration for stream manager
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamConfig {
/// Service endpoints to connect to
pub endpoints: ServiceEndpoints,
/// Maximum concurrent streams per service
pub max_concurrent_streams: usize,
/// Initial reconnection delay in milliseconds
pub initial_reconnect_delay_ms: u64,
/// Maximum reconnection delay in milliseconds
pub max_reconnect_delay_ms: u64,
/// Exponential backoff multiplier
pub backoff_multiplier: f64,
/// Maximum number of reconnection attempts (0 = infinite)
pub max_reconnect_attempts: u32,
/// Stream keepalive interval in seconds
pub keepalive_interval_secs: u64,
/// Connection timeout in seconds
pub connection_timeout_secs: u64,
/// Stream request timeout in seconds
pub stream_timeout_secs: u64,
/// Enable circuit breaker pattern
pub enable_circuit_breaker: bool,
/// Circuit breaker failure threshold
pub circuit_breaker_threshold: u32,
/// Circuit breaker recovery timeout in seconds
pub circuit_breaker_recovery_secs: u64,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
endpoints: ServiceEndpoints::default(),
max_concurrent_streams: 10_usize,
initial_reconnect_delay_ms: 1000_u64,
max_reconnect_delay_ms: 30000_u64,
backoff_multiplier: 2.0,
max_reconnect_attempts: 0_u32, // Infinite retries
keepalive_interval_secs: 30_u64,
connection_timeout_secs: 10_u64,
stream_timeout_secs: 60_u64,
enable_circuit_breaker: true,
circuit_breaker_threshold: 5_u32,
circuit_breaker_recovery_secs: 60_u64,
}
}
}
/// Stream health status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StreamHealth {
/// Stream is healthy and connected
Healthy,
/// Stream is connecting
Connecting,
/// Stream is reconnecting after failure
Reconnecting,
/// Stream has failed and stopped reconnecting
Failed,
/// Stream is disabled by circuit breaker
CircuitBreakerOpen,
}
/// Individual stream connection information
#[derive(Debug, Clone)]
pub struct StreamConnection {
/// Unique stream ID
pub id: Uuid,
/// Service name
pub service: String,
/// Service endpoint URL
pub endpoint: String,
/// Current health status
pub health: StreamHealth,
/// Connection start time
pub connected_at: Option<DateTime<Utc>>,
/// Last successful message time
pub last_message_at: Option<DateTime<Utc>>,
/// Number of reconnection attempts
pub reconnect_attempts: u32,
/// Next reconnection time
pub next_reconnect_at: Option<DateTime<Utc>>,
/// Total messages received
pub messages_received: u64,
/// Total bytes received
pub bytes_received: u64,
/// Last error message
pub last_error: Option<String>,
}
impl StreamConnection {
fn new(service: String, endpoint: String) -> Self {
Self {
id: Uuid::new_v4(),
service,
endpoint,
health: StreamHealth::Connecting,
connected_at: None,
last_message_at: None,
reconnect_attempts: 0_u32,
next_reconnect_at: None,
messages_received: 0_u64,
bytes_received: 0_u64,
last_error: None,
}
}
}
/// Circuit breaker for managing failed connections
#[derive(Debug)]
struct CircuitBreaker {
/// Number of consecutive failures
failure_count: u32,
/// Failure threshold before opening circuit
threshold: u32,
/// Time when circuit was opened
opened_at: Option<Instant>,
/// Recovery timeout duration
recovery_timeout: Duration,
/// Current circuit state
is_open: bool,
}
impl CircuitBreaker {
const fn new(threshold: u32, recovery_timeout: Duration) -> Self {
Self {
failure_count: 0_u32,
threshold,
opened_at: None,
recovery_timeout,
is_open: false,
}
}
fn record_success(&mut self) {
self.failure_count = 0;
self.is_open = false;
self.opened_at = None;
}
fn record_failure(&mut self) {
self.failure_count += 1;
if self.failure_count >= self.threshold {
self.is_open = true;
self.opened_at = Some(Instant::now());
}
}
fn can_attempt(&self) -> bool {
if !self.is_open {
return true;
}
if let Some(opened_at) = self.opened_at {
opened_at.elapsed() >= self.recovery_timeout
} else {
true
}
}
#[allow(dead_code)]
const fn is_circuit_open(&self) -> bool {
self.is_open
}
}
/// Main stream manager that handles all gRPC streaming connections
pub struct StreamManager {
/// Configuration
config: StreamConfig,
/// Active stream connections
connections: Arc<RwLock<HashMap<String, StreamConnection>>>,
/// Circuit breakers per service
circuit_breakers: Arc<RwLock<HashMap<String, CircuitBreaker>>>,
/// Concurrency limiter
concurrency_limiter: Arc<Semaphore>,
/// Sequence counter for events
sequence_counter: Arc<RwLock<u64>>,
}
impl StreamManager {
/// Create a new stream manager
pub async fn new(config: StreamConfig) -> TliResult<Self> {
let concurrency_limiter = Arc::new(Semaphore::new(config.max_concurrent_streams));
Ok(Self {
config,
connections: Arc::new(RwLock::new(HashMap::new())),
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
concurrency_limiter,
sequence_counter: Arc::new(RwLock::new(0)),
})
}
/// Start streaming from all configured services
#[instrument(skip(self, _event_sender, shutdown_receiver))]
pub async fn start(
&self,
_event_sender: broadcast::Sender<Event>,
mut shutdown_receiver: tokio::sync::watch::Receiver<bool>,
) -> TliResult<()> {
info!("Starting stream manager");
// Initialize circuit breakers
{
let mut breakers = self.circuit_breakers.write().await;
let recovery_timeout = Duration::from_secs(self.config.circuit_breaker_recovery_secs);
breakers.insert(
"trading".to_owned(),
CircuitBreaker::new(self.config.circuit_breaker_threshold, recovery_timeout),
);
breakers.insert(
"monitoring".to_owned(),
CircuitBreaker::new(self.config.circuit_breaker_threshold, recovery_timeout),
);
}
// Start trading service stream
let trading_manager = self.clone();
let trading_sender = _event_sender.clone();
let trading_shutdown = shutdown_receiver.clone();
tokio::spawn(async move {
trading_manager
.manage_trading_stream(trading_sender, trading_shutdown)
.await;
});
// Start monitoring service stream
let monitoring_manager = self.clone();
let monitoring_sender = _event_sender.clone();
let monitoring_shutdown = shutdown_receiver.clone();
tokio::spawn(async move {
monitoring_manager
.manage_monitoring_stream(monitoring_sender, monitoring_shutdown)
.await;
});
// Start health monitoring
let health_manager = self.clone();
let health_shutdown = shutdown_receiver.clone();
tokio::spawn(async move {
health_manager.monitor_stream_health(health_shutdown).await;
});
// Wait for shutdown signal
while !*shutdown_receiver.borrow() {
if shutdown_receiver.changed().await.is_err() {
break;
}
}
info!("Stream manager shutting down");
Ok(())
}
/// Manage trading service stream with reconnection
async fn manage_trading_stream(
&self,
_event_sender: broadcast::Sender<Event>,
mut shutdown_receiver: tokio::sync::watch::Receiver<bool>,
) {
let service_name = "trading".to_owned();
let endpoint = self.config.endpoints.trading_engine.clone();
loop {
if *shutdown_receiver.borrow() {
break;
}
// Check circuit breaker
if !self.can_attempt_connection(&service_name).await {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
// Acquire concurrency permit
let permit = if let Ok(permit) = self.concurrency_limiter.try_acquire() {
permit
} else {
warn!("Too many concurrent streams, waiting...");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
};
match self.connect_trading_stream(&endpoint).await {
Ok(mut stream) => {
info!("Connected to trading service: {}", endpoint);
self.update_connection_health(&service_name, StreamHealth::Healthy, None)
.await;
self.record_circuit_breaker_success(&service_name).await;
// Process stream messages
while let Some(result) = stream.next().await {
if *shutdown_receiver.borrow() {
break;
}
match result {
Ok(response) => {
if let Err(e) = self
.process_trading_response(
&service_name,
response,
&_event_sender,
)
.await
{
error!("Failed to process trading response: {}", e);
}
},
Err(e) => {
error!("Trading stream error: {}", e);
self.update_connection_health(
&service_name,
StreamHealth::Failed,
Some(e.to_string()),
)
.await;
break;
},
}
}
},
Err(e) => {
error!("Failed to connect to trading service: {}", e);
self.update_connection_health(
&service_name,
StreamHealth::Failed,
Some(e.to_string()),
)
.await;
self.record_circuit_breaker_failure(&service_name).await;
},
}
drop(permit);
// Wait before reconnecting
let delay = self.calculate_reconnect_delay(&service_name).await;
self.update_connection_health(&service_name, StreamHealth::Reconnecting, None)
.await;
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = shutdown_receiver.changed() => {
if *shutdown_receiver.borrow() {
break;
}
}
}
}
}
/// Manage monitoring service stream with reconnection
async fn manage_monitoring_stream(
&self,
_event_sender: broadcast::Sender<Event>,
mut shutdown_receiver: tokio::sync::watch::Receiver<bool>,
) {
let service_name = "monitoring".to_owned();
let endpoint = self.config.endpoints.market_data.clone();
loop {
if *shutdown_receiver.borrow() {
break;
}
// Check circuit breaker
if !self.can_attempt_connection(&service_name).await {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
// Acquire concurrency permit
let permit = if let Ok(permit) = self.concurrency_limiter.try_acquire() {
permit
} else {
warn!("Too many concurrent streams, waiting...");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
};
match self.connect_monitoring_stream(&endpoint).await {
Ok(mut stream) => {
info!("Connected to monitoring service: {}", endpoint);
self.update_connection_health(&service_name, StreamHealth::Healthy, None)
.await;
self.record_circuit_breaker_success(&service_name).await;
// Process stream messages
while let Some(result) = stream.next().await {
if *shutdown_receiver.borrow() {
break;
}
match result {
Ok(response) => {
if let Err(e) = self
.process_monitoring_response(
&service_name,
response,
&_event_sender,
)
.await
{
error!("Failed to process monitoring response: {}", e);
}
},
Err(e) => {
error!("Monitoring stream error: {}", e);
self.update_connection_health(
&service_name,
StreamHealth::Failed,
Some(e.to_string()),
)
.await;
break;
},
}
}
},
Err(e) => {
error!("Failed to connect to monitoring service: {}", e);
self.update_connection_health(
&service_name,
StreamHealth::Failed,
Some(e.to_string()),
)
.await;
self.record_circuit_breaker_failure(&service_name).await;
},
}
drop(permit);
// Wait before reconnecting
let delay = self.calculate_reconnect_delay(&service_name).await;
self.update_connection_health(&service_name, StreamHealth::Reconnecting, None)
.await;
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = shutdown_receiver.changed() => {
if *shutdown_receiver.borrow() {
break;
}
}
}
}
}
/// Connect to trading service metrics stream
async fn connect_trading_stream(&self, endpoint: &str) -> TliResult<Streaming<MetricsEvent>> {
let channel = self.create_channel(endpoint).await?;
let mut client = TradingServiceClient::new(channel);
let request = Request::new(SubscribeMetricsRequest {
metric_names: vec![
"order_latency".to_owned(),
"execution_rate".to_owned(),
"pnl".to_owned(),
],
interval_seconds: 1,
});
let response = client.subscribe_metrics(request).await.map_err(|e| {
TliError::Connection(format!("Failed to start trading metrics stream: {}", e))
})?;
Ok(response.into_inner())
}
/// Connect to system status stream
async fn connect_monitoring_stream(
&self,
endpoint: &str,
) -> TliResult<Streaming<SystemStatusEvent>> {
let channel = self.create_channel(endpoint).await?;
let mut client = TradingServiceClient::new(channel);
let request = Request::new(SubscribeSystemStatusRequest {
service_names: vec!["trading".to_owned(), "risk".to_owned(), "ml".to_owned()],
});
let response = client.subscribe_system_status(request).await.map_err(|e| {
TliError::Connection(format!("Failed to start system status stream: {}", e))
})?;
Ok(response.into_inner())
}
/// Create gRPC channel with timeouts
async fn create_channel(&self, endpoint: &str) -> TliResult<Channel> {
let channel = Endpoint::from_shared(endpoint.to_owned())
.map_err(|e| TliError::Connection(format!("Invalid endpoint {}: {}", endpoint, e)))?
.timeout(Duration::from_secs(self.config.stream_timeout_secs))
.connect_timeout(Duration::from_secs(self.config.connection_timeout_secs))
.connect()
.await
.map_err(|e| {
TliError::Connection(format!("Failed to connect to {}: {}", endpoint, e))
})?;
Ok(channel)
}
/// Process trading metrics response
async fn process_trading_response(
&self,
service_name: &str,
response: MetricsEvent,
_event_sender: &broadcast::Sender<Event>,
) -> TliResult<()> {
let sequence = self.next_sequence().await;
let event_type = EventType::System;
let severity = EventSeverity::Info;
// Convert metrics to JSON payload
let payload = serde_json::json!({
"timestamp": response.timestamp_unix_nanos,
"metrics": response.metrics.iter().map(|m| {
serde_json::json!({
"name": m.name,
"value": m.value,
"unit": m.unit,
"labels": m.labels,
"timestamp": m.timestamp_unix_nanos
})
}).collect::<Vec<_>>()
});
let mut event = Event::new(event_type, severity, service_name.to_owned(), payload);
event.set_sequence(sequence);
// Add metadata
event.add_metadata(
"metric_count".to_owned(),
response.metrics.len().to_string(),
);
// Update connection stats (estimate payload size)
let payload_size = response.metrics.len() * 100; // Rough estimate
self.update_connection_stats(service_name, payload_size as u64)
.await;
// Send event
if let Err(e) = _event_sender.send(event) {
warn!("Failed to send event: {}", e);
}
Ok(())
}
/// Process system status response
async fn process_monitoring_response(
&self,
service_name: &str,
response: SystemStatusEvent,
_event_sender: &broadcast::Sender<Event>,
) -> TliResult<()> {
let sequence = self.next_sequence().await;
let event_type = EventType::System;
// Map status to severity
let severity = match response.status {
0 => EventSeverity::Warning, // Unknown
1 => EventSeverity::Info, // Healthy
2 => EventSeverity::Warning, // Degraded
3 => EventSeverity::Error, // Unhealthy
4 => EventSeverity::Critical, // Critical
_ => EventSeverity::Warning,
};
// Convert status event to JSON payload
let payload = serde_json::json!({
"service_name": response.service_name,
"status": response.status,
"previous_status": response.previous_status,
"message": response.message,
"timestamp": response.timestamp_unix_nanos
});
let mut event = Event::new(event_type, severity, service_name.to_owned(), payload);
event.set_sequence(sequence);
// Add metadata
event.add_metadata("affected_service".to_owned(), response.service_name.clone());
event.add_metadata("status_code".to_owned(), response.status.to_string());
// Update connection stats (estimate payload size)
let payload_size = response.message.len() + response.service_name.len() + 100;
self.update_connection_stats(service_name, payload_size as u64)
.await;
// Send event
if let Err(e) = _event_sender.send(event) {
warn!("Failed to send event: {}", e);
}
Ok(())
}
/// Monitor stream health and send health events
async fn monitor_stream_health(
&self,
mut shutdown_receiver: tokio::sync::watch::Receiver<bool>,
) {
let mut interval =
tokio::time::interval(Duration::from_secs(self.config.keepalive_interval_secs));
while !*shutdown_receiver.borrow() {
tokio::select! {
_ = interval.tick() => {
self.check_connection_health().await;
}
_ = shutdown_receiver.changed() => {
if *shutdown_receiver.borrow() {
break;
}
}
}
}
}
/// Check health of all connections
async fn check_connection_health(&self) {
let connections = self.connections.read().await;
let now = Utc::now();
for (service, connection) in connections.iter() {
if let Some(last_message) = connection.last_message_at {
let elapsed = now.signed_duration_since(last_message);
if elapsed.num_seconds() > (self.config.keepalive_interval_secs * 2) as i64 {
warn!(
"Stream {} appears stale, last message {} seconds ago",
service,
elapsed.num_seconds()
);
}
}
}
}
/// Get next sequence number
async fn next_sequence(&self) -> u64 {
let mut counter = self.sequence_counter.write().await;
*counter += 1;
*counter
}
/// Update connection health status
async fn update_connection_health(
&self,
service: &str,
health: StreamHealth,
error: Option<String>,
) {
let mut connections = self.connections.write().await;
let connection = connections
.entry(service.to_owned())
.or_insert_with(|| StreamConnection::new(service.to_owned(), "".to_owned()));
connection.health = health.clone();
match health {
StreamHealth::Healthy => {
connection.connected_at = Some(Utc::now());
connection.reconnect_attempts = 0;
connection.last_error = None;
},
StreamHealth::Failed => {
connection.connected_at = None;
connection.reconnect_attempts += 1;
connection.last_error = error;
},
StreamHealth::Reconnecting => {
let delay_ms = self.calculate_reconnect_delay_ms(connection.reconnect_attempts);
connection.next_reconnect_at =
Some(Utc::now() + chrono::Duration::milliseconds(delay_ms as i64));
},
_ => {},
}
}
/// Update connection statistics
async fn update_connection_stats(&self, service: &str, bytes_received: u64) {
let mut connections = self.connections.write().await;
if let Some(connection) = connections.get_mut(service) {
connection.messages_received += 1;
connection.bytes_received += bytes_received;
connection.last_message_at = Some(Utc::now());
}
}
/// Check if connection attempt is allowed by circuit breaker
async fn can_attempt_connection(&self, service: &str) -> bool {
if !self.config.enable_circuit_breaker {
return true;
}
let breakers = self.circuit_breakers.read().await;
if let Some(breaker) = breakers.get(service) {
breaker.can_attempt()
} else {
true
}
}
/// Record successful connection for circuit breaker
async fn record_circuit_breaker_success(&self, service: &str) {
if !self.config.enable_circuit_breaker {
return;
}
let mut breakers = self.circuit_breakers.write().await;
if let Some(breaker) = breakers.get_mut(service) {
breaker.record_success();
}
}
/// Record failed connection for circuit breaker
async fn record_circuit_breaker_failure(&self, service: &str) {
if !self.config.enable_circuit_breaker {
return;
}
let mut breakers = self.circuit_breakers.write().await;
if let Some(breaker) = breakers.get_mut(service) {
breaker.record_failure();
}
}
/// Calculate reconnection delay
async fn calculate_reconnect_delay(&self, service: &str) -> Duration {
let connections = self.connections.read().await;
if let Some(connection) = connections.get(service) {
let delay_ms = self.calculate_reconnect_delay_ms(connection.reconnect_attempts);
Duration::from_millis(delay_ms)
} else {
Duration::from_millis(self.config.initial_reconnect_delay_ms)
}
}
/// Calculate reconnection delay in milliseconds
fn calculate_reconnect_delay_ms(&self, attempts: u32) -> u64 {
let delay = self.config.initial_reconnect_delay_ms as f64
* self.config.backoff_multiplier.powi(attempts as i32);
(delay as u64).min(self.config.max_reconnect_delay_ms)
}
/// Get current stream health status
pub async fn get_stream_health(&self) -> HashMap<String, StreamHealth> {
let connections = self.connections.read().await;
connections
.iter()
.map(|(service, connection)| (service.clone(), connection.health.clone()))
.collect()
}
/// Get detailed connection information
pub async fn get_connections(&self) -> Vec<StreamConnection> {
let connections = self.connections.read().await;
connections.values().cloned().collect()
}
}
impl Clone for StreamManager {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
connections: self.connections.clone(),
circuit_breakers: self.circuit_breakers.clone(),
concurrency_limiter: self.concurrency_limiter.clone(),
sequence_counter: self.sequence_counter.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circuit_breaker() {
let mut breaker = CircuitBreaker::new(3_u32, Duration::from_secs(60));
// Initial state
assert!(breaker.can_attempt());
assert!(!breaker.is_circuit_open());
// Record failures
breaker.record_failure();
breaker.record_failure();
assert!(breaker.can_attempt());
breaker.record_failure(); // Should open circuit
assert!(!breaker.can_attempt());
assert!(breaker.is_circuit_open());
// Success should reset
breaker.record_success();
assert!(breaker.can_attempt());
assert!(!breaker.is_circuit_open());
}
#[test]
fn test_reconnect_delay_calculation() {
let config = StreamConfig::default();
let manager = StreamManager {
config: config.clone(),
connections: Arc::new(RwLock::new(HashMap::new())),
circuit_breakers: Arc::new(RwLock::new(HashMap::new())),
concurrency_limiter: Arc::new(Semaphore::new(config.max_concurrent_streams)),
sequence_counter: Arc::new(RwLock::new(0)),
};
assert_eq!(manager.calculate_reconnect_delay_ms(0), 1000);
assert_eq!(manager.calculate_reconnect_delay_ms(1), 2000);
assert_eq!(manager.calculate_reconnect_delay_ms(2), 4000);
assert_eq!(manager.calculate_reconnect_delay_ms(10), 30000); // Capped at max
}
#[test]
fn test_stream_connection_creation() {
let connection = StreamConnection::new("test".to_owned(), "http://test".to_owned());
assert_eq!(connection.service, "test");
assert_eq!(connection.endpoint, "http://test");
assert_eq!(connection.health, StreamHealth::Connecting);
assert_eq!(connection.reconnect_attempts, 0);
assert_eq!(connection.messages_received, 0);
}
}

View File

@@ -66,16 +66,13 @@
//! ```
// Suppress false-positive unused extern crate warnings for dependencies used in modules
use adaptive_strategy as _;
use chrono as _;
use clap as _;
use colored as _;
use common as _;
use crossterm as _;
use dirs as _; // Used in config module for home directory
use futures_util as _;
use prost as _;
use ratatui as _;
use rust_decimal as _;
use serde as _;
use serde_json as _;
@@ -91,16 +88,8 @@ pub mod auth;
pub mod client;
pub mod commands;
pub mod config; // Configuration file support (~/.foxhunt/config.toml)
// pub mod config_client; // Config client removed - use gRPC ConfigurationService instead
pub mod dashboard;
pub mod dashboards;
pub mod error;
// pub mod health; // Health server module removed - TLI is pure client
pub mod types;
pub mod ui;
// Event system for client-side event handling and streaming
pub mod events;
// Prelude module for convenient imports
pub mod prelude;
@@ -140,7 +129,6 @@ pub const BUILD_INFO: BuildInfo = BuildInfo {
features: &[
// TLI features are defined by dependencies, not cargo features
"tonic-tls",
"ratatui-ui",
"grpc-client",
],
};

View File

@@ -1,11 +1,9 @@
//! TLI (Terminal Line Interface) - Client Application for Foxhunt HFT Trading System
//!
//! Pure client terminal application that connects to trading services:
//! - Real-time trading dashboard with 5 specialized views
//! - Interactive terminal UI using Ratatui
//! 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
//! - Live data streaming and event handling
//! - Remote monitoring and control capabilities
//! - Authentication and secure token management
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
@@ -15,7 +13,6 @@ use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use tli::auth::token_manager::FileTokenStorage;
use tli::{
client::TliClientBuilder,
commands::{
agent::{execute_agent_command, AgentArgs},
auth::{execute_auth_command, AuthCommand},
@@ -25,13 +22,11 @@ use tli::{
tune::{execute_tune_command, TuneCommand},
},
config::TliConfig,
ui::TliTerminal,
};
use tracing::{error, info, Level};
use tracing::Level;
use tracing_subscriber::FmtSubscriber;
// Suppress false-positive unused extern crate warnings for dependencies used in modules
use adaptive_strategy as _;
use aes_gcm as _;
use argon2 as _;
use async_trait as _;
@@ -42,7 +37,6 @@ use colored as _;
use comfy_table as _;
use common as _;
use console as _;
use crossterm as _;
use dirs as _;
use futures_util as _;
use getrandom as _;
@@ -52,7 +46,6 @@ use keyring as _;
use owo_colors as _;
use prost as _;
use rand as _;
use ratatui as _;
use rpassword as _;
use rust_decimal as _;
use serde as _;
@@ -199,17 +192,6 @@ enum Commands {
trade_args: TradeArgs,
},
/// Launch interactive trading dashboard (TUI)
#[clap(long_about = "Real-time trading dashboard with:\n\
- Live position monitoring\n\
- P&L tracking\n\
- Risk metrics (VaR, Greeks)\n\
- Order flow visualization\n\n\
Keyboard shortcuts:\n\
q - Quit\n\
r - Refresh\n\
h - Help")]
Dashboard,
}
/// JWT token claims structure for validation
@@ -404,101 +386,29 @@ async fn main() -> Result<()> {
eprintln!("Warning: Failed to set global tracing subscriber: {}", e);
}
// Route commands before launching dashboard
// Route to subcommands
match cli.command {
Commands::Tune { tune_cmd } => {
// Get JWT token from storage for tune commands
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
// Execute tune command
return execute_tune_command(tune_cmd, &cli.api_gateway_url, &jwt_token).await;
},
execute_tune_command(tune_cmd, &cli.api_gateway_url, &jwt_token).await
}
Commands::Train { train_cmd } => {
// Get JWT token from storage for train commands
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
// Execute train command
return execute_train_command(train_cmd, &cli.api_gateway_url, &jwt_token).await;
},
Commands::Auth { auth_cmd } => {
// Execute auth command (auth commands don't need prior authentication)
return execute_auth_command(auth_cmd).await;
},
execute_train_command(train_cmd, &cli.api_gateway_url, &jwt_token).await
}
Commands::Auth { auth_cmd } => execute_auth_command(auth_cmd).await,
Commands::Agent { agent_args } => {
// Get JWT token from storage for agent commands
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
return execute_agent_command(agent_args, &cli.api_gateway_url, &jwt_token).await;
},
execute_agent_command(agent_args, &cli.api_gateway_url, &jwt_token).await
}
Commands::Backtest { backtest_args } => {
// Backtest commands don't require authentication for now
return execute_backtest_ml_command(backtest_args).await;
},
execute_backtest_ml_command(backtest_args).await
}
Commands::Trade { trade_args } => {
// Get JWT token from storage for trade commands
let jwt_token = load_jwt_token(&cli.api_gateway_url).await?;
return execute_trade_command(trade_args, &cli.api_gateway_url, &jwt_token).await;
},
Commands::Dashboard => {
// Continue to launch dashboard
},
execute_trade_command(trade_args, &cli.api_gateway_url, &jwt_token).await
}
}
info!("Starting TLI Terminal Client...");
// Use CLI api_gateway_url (already merged with config)
let api_gateway_endpoint = cli.api_gateway_url.clone();
info!("TLI Client Configuration:");
info!(" API Gateway: {}", api_gateway_endpoint);
info!(" Log Level: {}", cli.log_level);
info!(" Token Storage: {}", cli.token_storage);
info!(" Note: TLI connects ONLY to API Gateway (port 50051)");
info!(" API Gateway routes to backend services (Trading, Backtesting, ML)");
// Create TLI terminal
let (mut terminal, _event_sender) = TliTerminal::new();
// Create gRPC client - connects ONLY to API Gateway (pure client architecture)
match TliClientBuilder::new()
.with_service_endpoint("api_gateway".to_owned(), api_gateway_endpoint)
.build()
.await
{
Ok(client_suite) => {
info!("Successfully connected to API Gateway at port 50051");
terminal.set_client_suite(client_suite);
},
Err(e) => {
error!("Failed to connect to API Gateway: {}", e);
info!("Running in offline mode - dashboard will show demo data");
},
}
// Start real-time data streaming (works in both online and offline modes)
if let Err(e) = terminal.start_streaming().await {
error!("Failed to start data streams: {}", e);
info!("Dashboard will show static data only");
} else {
info!("Real-time data streaming started");
}
info!("Starting TLI Terminal Interface...");
info!("6 Interactive Dashboards (per TLI_PLAN.md):");
info!(" [T] Trading Dashboard - Live positions, orders, executions, market data");
info!(" [R] Risk Dashboard - VaR, limits, drawdown, emergency controls");
info!(" [M] ML Dashboard - Model predictions, signals, ensemble voting");
info!(" [P] Performance Dashboard - Returns, Sharpe ratios, analytics");
info!(" [B] Backtesting Dashboard - Strategy testing, historical analysis");
info!(" [C] Configuration Dashboard - Settings management, hot-reload");
info!(" [ESC/Q] Exit");
// Run the terminal application
if let Err(e) = terminal.run().await {
error!("Terminal application error: {}", e);
return Err(e);
}
info!("TLI Terminal Client stopped gracefully");
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
@@ -543,20 +453,10 @@ mod tests {
}
}
#[test]
fn test_cli_parsing_dashboard_command() {
let cli = Cli::parse_from(&["tli", "dashboard"]);
match cli.command {
Commands::Dashboard => {},
_ => panic!("Expected Dashboard command"),
}
}
#[test]
fn test_cli_default_values() {
// Test default values when no flags provided
let cli = Cli::parse_from(&["tli", "dashboard"]);
let cli = Cli::parse_from(&["tli", "auth", "status"]);
assert_eq!(cli.api_gateway_url, "http://localhost:50051");
assert_eq!(cli.log_level, "info");
@@ -574,7 +474,8 @@ mod tests {
"debug",
"--token-storage",
"file",
"dashboard",
"auth",
"status",
]);
assert_eq!(cli.api_gateway_url, "http://custom.com:8080");

View File

@@ -12,19 +12,11 @@ pub use crate::client::{ClientFactory, ServiceEndpoints, TliClientBuilder, TliCl
// Client configurations and implementations
pub use crate::client::backtesting_client::{BacktestingClient, BacktestingClientConfig};
pub use crate::client::connection_manager::{ConnectionConfig, ConnectionManager};
pub use crate::client::data_stream::{DataStreamConfig, DataStreamManager};
pub use crate::client::event_stream::{EventStreamConfig, EventStreamManager};
pub use crate::client::ml_training_client::{MLTrainingClient, MLTrainingClientConfig};
pub use crate::client::trading_client::{TradingClient, TradingClientConfig};
// Event types
pub use crate::events::{Event, EventFilter, EventSeverity, EventType};
// Proto types - common trading types
pub use crate::proto::trading::{
CancelOrderRequest, GetPositionsRequest, OrderSide, OrderStatus, OrderType, Position,
SubmitOrderRequest, Trade,
};
// Dashboard types if needed
pub use crate::dashboard::Dashboard;

View File

@@ -454,25 +454,6 @@ mod benchmark_helpers {
}
}
#[cfg(test)]
mod ui_state_tests {
use crate::ui::TliTerminal;
#[test]
fn test_terminal_creation() {
let (_terminal, _event_sender) = TliTerminal::new();
// Test that terminal can be created successfully
assert!(true);
}
#[test]
fn test_terminal_default() {
let _terminal = TliTerminal::default();
// Test that terminal can be created with default
assert!(true);
}
}
#[cfg(test)]
mod command_handling_tests {
use crate::types::*;

View File

@@ -1,131 +0,0 @@
//! Terminal UI Module for TLI Client
//!
//! Provides the main terminal user interface implementation using Ratatui
//! and integrates with the dashboard framework for a complete trading terminal.
use crate::client::data_stream::DataStreamManager;
use crate::client::TliClientSuite;
use crate::dashboard::events::DashboardEvent;
use crate::dashboard::DashboardManager;
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use std::io;
use tokio::sync::mpsc;
pub struct TliTerminal {
dashboard_manager: DashboardManager,
client_suite: Option<TliClientSuite>,
_event_sender: mpsc::Sender<DashboardEvent>,
stream_manager: Option<DataStreamManager>,
}
impl TliTerminal {
pub fn new() -> (Self, mpsc::Sender<DashboardEvent>) {
let (dashboard_manager, _event_sender) = DashboardManager::new();
let terminal = Self {
dashboard_manager,
client_suite: None,
_event_sender: _event_sender.clone(),
stream_manager: None,
};
(terminal, _event_sender)
}
pub fn set_client_suite(&mut self, client_suite: TliClientSuite) {
self.client_suite = Some(client_suite);
}
pub async fn start_streaming(&mut self) -> Result<()> {
// Initialize stream manager for real-time data
use crate::client::data_stream::DataStreamConfig;
let config = DataStreamConfig {
buffer_size: 1000_usize,
max_latency_ms: 100_u64,
};
let mut stream_manager = DataStreamManager::new(config);
stream_manager
.start_streams()
.await
.map_err(|e| anyhow::anyhow!(e))?;
self.stream_manager = Some(stream_manager);
Ok(())
}
pub async fn run(&mut self) -> Result<()> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = self.run_app(&mut terminal).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
async fn run_app<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
loop {
// Render the UI
terminal.draw(|f| {
if let Err(e) = self.dashboard_manager.render(f) {
eprintln!("Render error: {}", e);
}
})?;
// Handle events
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
// Handle global shortcuts
if key.code == KeyCode::Char('q') || key.code == KeyCode::Esc {
break;
}
// Pass to dashboard manager
if let Some(dashboard_event) = self.dashboard_manager.handle_input(key)? {
let should_exit =
self.dashboard_manager.handle_event(dashboard_event).await?;
if should_exit {
break;
}
}
}
}
// Process any pending dashboard events
while let Ok(event) = self.dashboard_manager.event_receiver.try_recv() {
let should_exit = self.dashboard_manager.handle_event(event).await?;
if should_exit {
break;
}
}
}
Ok(())
}
}
impl Default for TliTerminal {
fn default() -> Self {
Self::new().0
}
}

View File

@@ -1,491 +0,0 @@
//! Real-time candlestick chart widget for financial data visualization
//!
//! Displays OHLC (Open, High, Low, Close) price data as candlesticks with:
//! - Real-time updates with minimal flicker
//! - Auto-scaling based on visible data range
//! - Volume indicators at the bottom
//! - Price grid lines and labels
//! - Interactive zoom and pan (future enhancement)
use ratatui::{
prelude::*,
symbols::DOT,
widgets::{Block, Borders, Widget, canvas::{Canvas, Line, Points}},
};
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use super::{
FinancialWidget, FinancialColors, Candle, CircularBuffer,
create_block, format_price, price_change_color
};
/// Real-time candlestick chart widget
#[derive(Debug)]
pub struct CandlestickChart {
/// Chart title
title: String,
/// Candlestick data buffer
candles: CircularBuffer<Candle>,
/// Color scheme
colors: FinancialColors,
/// Chart dimensions
width: u16,
height: u16,
/// Price precision for display
price_precision: u32,
/// Show volume bars
show_volume: bool,
/// Auto-scale prices
auto_scale: bool,
/// Manual price range (if not auto-scaling)
price_range: Option<(Decimal, Decimal)>,
/// Current price range for display
current_range: (Decimal, Decimal),
/// Time range in seconds
time_range_seconds: i64,
}
impl CandlestickChart {
/// Create a new candlestick chart
pub fn new(title: &str, max_candles: usize) -> Self {
Self {
title: title.to_string(),
candles: CircularBuffer::new(max_candles),
colors: FinancialColors::default(),
width: 80,
height: 30,
price_precision: 2,
show_volume: true,
auto_scale: true,
price_range: None,
current_range: (Decimal::ZERO, Decimal::ZERO),
time_range_seconds: 300, // 5 minutes default
}
}
/// Set chart dimensions
pub fn with_dimensions(mut self, width: u16, height: u16) -> Self {
self.width = width;
self.height = height;
self
}
/// Set price precision for display
pub fn with_precision(mut self, precision: u32) -> Self {
self.price_precision = precision;
self
}
/// Toggle volume display
pub fn with_volume(mut self, show_volume: bool) -> Self {
self.show_volume = show_volume;
self
}
/// Set manual price range
pub fn with_price_range(mut self, min: Decimal, max: Decimal) -> Self {
self.auto_scale = false;
self.price_range = Some((min, max));
self.current_range = (min, max);
self
}
/// Set time range in seconds
pub fn with_time_range(mut self, seconds: i64) -> Self {
self.time_range_seconds = seconds;
self
}
/// Add a single candle
pub fn add_candle(&mut self, candle: Candle) {
self.candles.push(candle);
if self.auto_scale {
self.update_price_range();
}
}
/// Update price range based on visible candles
fn update_price_range(&mut self) {
if self.candles.is_empty() {
return;
}
let mut min_price = Decimal::MAX;
let mut max_price = Decimal::MIN;
for candle in &self.candles {
min_price = min_price.min(candle.low);
max_price = max_price.max(candle.high);
}
// Add 5% padding to the range
let padding = (max_price - min_price) * Decimal::new(5, 2); // 0.05
self.current_range = (min_price - padding, max_price + padding);
}
/// Get the latest candle
pub fn latest_candle(&self) -> Option<&Candle> {
self.candles.iter().last()
}
/// Calculate price change from previous candle
pub fn price_change(&self) -> Option<(Decimal, Decimal)> {
let candles: Vec<&Candle> = self.candles.iter().collect();
if candles.len() < 2 {
return None;
}
let current = candles[candles.len() - 1];
let previous = candles[candles.len() - 2];
let change = current.close - previous.close;
let percentage = if previous.close != Decimal::ZERO {
(change / previous.close) * Decimal::new(100, 0)
} else {
Decimal::ZERO
};
Some((change, percentage))
}
/// Convert price to screen Y coordinate
fn price_to_y(&self, price: Decimal, chart_height: u16) -> f64 {
let (min_price, max_price) = self.current_range;
let price_range = max_price - min_price;
if price_range == Decimal::ZERO {
return (chart_height / 2) as f64;
}
let normalized = (price - min_price) / price_range;
let y = chart_height as f64 * (1.0 - normalized.to_f64());
y.clamp(0.0, chart_height as f64)
}
/// Convert candle index to screen X coordinate
fn index_to_x(&self, index: usize, chart_width: u16) -> f64 {
let candle_count = self.candles.len();
if candle_count <= 1 {
return 0.0;
}
let x = (index as f64 / (candle_count - 1) as f64) * chart_width as f64;
x.clamp(0.0, chart_width as f64)
}
/// Draw price grid lines
fn draw_price_grid(&self) -> Vec<Line> {
let mut lines = Vec::new();
let (min_price, max_price) = self.current_range;
let price_range = max_price - min_price;
if price_range == Decimal::ZERO {
return lines;
}
// Draw 5 horizontal grid lines
for i in 0..=4 {
let price = min_price + (price_range * Decimal::new(i, 0) / Decimal::new(4, 0));
let y = self.price_to_y(price, self.height);
lines.push(Line {
x1: 0.0,
y1: y,
x2: self.width as f64,
y2: y,
color: Color::DarkGray,
});
}
lines
}
/// Create candlestick visual elements
fn create_candlesticks(&self) -> (Vec<Line>, Vec<Line>) {
let mut wicks = Vec::new();
let mut bodies = Vec::new();
for (index, candle) in self.candles.into_iter().enumerate() {
let x = self.index_to_x(index, self.width);
let open_y = self.price_to_y(candle.open, self.height);
let high_y = self.price_to_y(candle.high, self.height);
let low_y = self.price_to_y(candle.low, self.height);
let close_y = self.price_to_y(candle.close, self.height);
// Determine candle color
let color = if candle.close >= candle.open {
self.colors.profit
} else {
self.colors.loss
};
// Draw wick (high-low line)
wicks.push(Line {
x1: x,
y1: high_y,
x2: x,
y2: low_y,
color,
});
// Draw body (open-close line, thicker)
bodies.push(Line {
x1: x,
y1: open_y,
x2: x,
y2: close_y,
color,
});
}
(wicks, bodies)
}
/// Create volume bars at the bottom
fn create_volume_bars(&self) -> Vec<Line> {
if !self.show_volume || self.candles.is_empty() {
return Vec::new();
}
let mut bars = Vec::new();
let volume_height = self.height as f64 * 0.2; // 20% of chart height for volume
// Find max volume for scaling
let max_volume = self.candles.iter()
.map(|c| c.volume)
.max()
.unwrap_or(Decimal::ZERO);
if max_volume == Decimal::ZERO {
return bars;
}
for (index, candle) in self.candles.into_iter().enumerate() {
let x = self.index_to_x(index, self.width);
let volume_ratio = candle.volume / max_volume;
let bar_height = volume_height * volume_ratio.to_f64();
bars.push(Line {
x1: x,
y1: self.height as f64,
x2: x,
y2: self.height as f64 - bar_height,
color: Color::Blue,
});
}
bars
}
}
impl FinancialWidget for CandlestickChart {
type Data = Vec<Candle>;
fn update_data(&mut self, candles: Self::Data) {
self.candles.clear();
for candle in candles {
self.candles.push(candle);
}
if self.auto_scale {
self.update_price_range();
}
}
fn clear(&mut self) {
self.candles.clear();
self.current_range = (Decimal::ZERO, Decimal::ZERO);
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
!self.candles.is_empty()
}
}
impl Widget for CandlestickChart {
fn render(self, area: Rect, buf: &mut Buffer) {
// Create the main block
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
if !self.has_data() {
// Show "No Data" message
let no_data = ratatui::widgets::Paragraph::new("No data available")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
}
// Create status line with current price and change
let status_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
if let Some(latest) = self.latest_candle() {
let mut status_text = format!("CLOSE: {}", format_price(latest.close, self.price_precision));
if let Some((change, percentage)) = self.price_change() {
let change_color = price_change_color(change, &self.colors);
status_text.push_str(&format!(" ({} {}%)",
if change >= Decimal::ZERO { "+" } else { "" },
format_price(percentage, 2)
));
}
let status = ratatui::widgets::Paragraph::new(status_text)
.style(Style::default().fg(self.colors.text));
status.render(status_area, buf);
}
// Chart area (below status line)
let chart_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
// Create canvas for drawing
let canvas = Canvas::default()
.block(Block::default())
.x_bounds([0.0, self.width as f64])
.y_bounds([0.0, self.height as f64])
.paint(|ctx| {
// Draw price grid
for line in self.draw_price_grid() {
ctx.draw(&line);
}
// Draw volume bars (if enabled)
if self.show_volume {
for bar in self.create_volume_bars() {
ctx.draw(&bar);
}
}
// Draw candlesticks
let (wicks, bodies) = self.create_candlesticks();
for wick in wicks {
ctx.draw(&wick);
}
for body in bodies {
ctx.draw(&body);
}
});
canvas.render(chart_area, buf);
// Price labels on the right side
let (min_price, max_price) = self.current_range;
let label_area = Rect {
x: inner.x + inner.width.saturating_sub(10),
y: inner.y + 1,
width: 10,
height: inner.height.saturating_sub(1),
};
let price_labels = vec![
format_price(max_price, self.price_precision),
format_price((max_price + min_price) / Decimal::new(2, 0), self.price_precision),
format_price(min_price, self.price_precision),
];
for (i, label) in price_labels.into_iter().enumerate() {
let y = label_area.y + (i as u16 * (label_area.height / 3));
let label_widget = ratatui::widgets::Paragraph::new(label.as_str())
.style(Style::default().fg(self.colors.text));
let label_rect = Rect {
x: label_area.x,
y,
width: label_area.width,
height: 1,
};
label_widget.render(label_rect, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_candle(open: f64, high: f64, low: f64, close: f64) -> Candle {
Candle {
timestamp: Utc::now(),
open: Decimal::try_from(open).unwrap(),
high: Decimal::try_from(high).unwrap(),
low: Decimal::try_from(low).unwrap(),
close: Decimal::try_from(close).unwrap(),
volume: Decimal::from(1000),
}
}
#[test]
fn test_candlestick_chart_creation() {
let chart = CandlestickChart::new("Test Chart", 100);
assert_eq!(chart.title(), "Test Chart");
assert!(!chart.has_data());
}
#[test]
fn test_add_candle() {
let mut chart = CandlestickChart::new("Test", 10);
let candle = create_test_candle(100.0, 105.0, 95.0, 102.0);
chart.add_candle(candle);
assert!(chart.has_data());
assert!(chart.latest_candle().is_some());
}
#[test]
fn test_price_change_calculation() {
let mut chart = CandlestickChart::new("Test", 10);
chart.add_candle(create_test_candle(100.0, 105.0, 95.0, 102.0));
chart.add_candle(create_test_candle(102.0, 108.0, 98.0, 105.0));
let (change, percentage) = chart.price_change().unwrap();
assert_eq!(change, Decimal::from(3)); // 105 - 102
assert!(percentage > Decimal::ZERO);
}
#[test]
fn test_coordinate_conversion() {
let mut chart = CandlestickChart::new("Test", 10);
chart.current_range = (Decimal::from(100), Decimal::from(200));
let y = chart.price_to_y(Decimal::from(150), 100);
assert_eq!(y, 50.0); // Middle of range should map to middle of height
let x = chart.index_to_x(5, 100);
assert!(x >= 0.0 && x <= 100.0);
}
#[test]
fn test_update_data() {
let mut chart = CandlestickChart::new("Test", 10);
let candles = vec![
create_test_candle(100.0, 105.0, 95.0, 102.0),
create_test_candle(102.0, 108.0, 98.0, 105.0),
];
chart.update_data(candles);
assert_eq!(chart.candles.len(), 2);
assert!(chart.has_data());
}
}

View File

@@ -1,699 +0,0 @@
//! Configuration form widget with input validation
//!
//! Provides interactive forms for:
//! - Trading strategy parameters
//! - Risk management settings
//! - Connection configurations
//! - System preferences
//!
//! Features include real-time validation, keyboard navigation,
//! and different input field types (text, numeric, boolean, select).
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget, Paragraph, List, ListItem, ListState, Clear},
};
use std::collections::HashMap;
use super::{
FinancialWidget, FinancialColors, ConfigField, FormField,
create_block
};
/// Form input mode
#[derive(Debug, Clone, PartialEq)]
pub enum InputMode {
Normal, // Navigation mode
Editing, // Text input mode
}
/// Form validation result
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: HashMap<String, String>,
}
/// Configuration form widget
#[derive(Debug)]
pub struct ConfigForm {
/// Widget title
title: String,
/// Form fields
fields: Vec<FormField>,
/// Color scheme
colors: FinancialColors,
/// Current input mode
input_mode: InputMode,
/// Currently selected field index
selected_field: usize,
/// Current input buffer (for text editing)
input_buffer: String,
/// Form validation result
validation: ValidationResult,
/// Show validation errors inline
show_inline_errors: bool,
/// Form is submittable
can_submit: bool,
/// Custom validators
validators: HashMap<String, Box<dyn Fn(&str) -> Result<(), String>>>,
}
impl ConfigForm {
/// Create a new configuration form
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
fields: Vec::new(),
colors: FinancialColors::default(),
input_mode: InputMode::Normal,
selected_field: 0,
input_buffer: String::new(),
validation: ValidationResult {
is_valid: true,
errors: HashMap::new(),
},
show_inline_errors: true,
can_submit: false,
validators: HashMap::new(),
}
}
/// Add a text field to the form
pub fn add_text_field(
mut self,
name: &str,
label: &str,
placeholder: &str,
required: bool,
) -> Self {
self.fields.push(FormField {
name: name.to_string(),
label: label.to_string(),
field_type: ConfigField::Text {
value: String::new(),
placeholder: placeholder.to_string(),
},
required,
validation_error: None,
});
self
}
/// Add a numeric field to the form
pub fn add_number_field(
mut self,
name: &str,
label: &str,
default_value: f64,
min: f64,
max: f64,
required: bool,
) -> Self {
self.fields.push(FormField {
name: name.to_string(),
label: label.to_string(),
field_type: ConfigField::Number {
value: default_value,
min,
max,
},
required,
validation_error: None,
});
self
}
/// Add a boolean field to the form
pub fn add_boolean_field(
mut self,
name: &str,
label: &str,
default_value: bool,
) -> Self {
self.fields.push(FormField {
name: name.to_string(),
label: label.to_string(),
field_type: ConfigField::Boolean { value: default_value },
required: false,
validation_error: None,
});
self
}
/// Add a select field to the form
pub fn add_select_field(
mut self,
name: &str,
label: &str,
options: Vec<String>,
default_value: Option<String>,
required: bool,
) -> Self {
let value = default_value.unwrap_or_else(|| {
options.first().cloned().unwrap_or_default()
});
self.fields.push(FormField {
name: name.to_string(),
label: label.to_string(),
field_type: ConfigField::Select { value, options },
required,
validation_error: None,
});
self
}
/// Add custom validator for a field
pub fn add_validator<F>(mut self, field_name: &str, validator: F) -> Self
where
F: Fn(&str) -> Result<(), String> + 'static,
{
self.validators.insert(field_name.to_string(), Box::new(validator));
self
}
/// Toggle inline error display
pub fn with_inline_errors(mut self, show: bool) -> Self {
self.show_inline_errors = show;
self
}
/// Handle keyboard input
pub fn handle_input(&mut self, key: crossterm::event::KeyCode) -> bool {
match self.input_mode {
InputMode::Normal => self.handle_navigation(key),
InputMode::Editing => self.handle_text_input(key),
}
}
/// Handle navigation keys in normal mode
fn handle_navigation(&mut self, key: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
match key {
KeyCode::Up => {
if self.selected_field > 0 {
self.selected_field -= 1;
}
false
},
KeyCode::Down => {
if self.selected_field < self.fields.len().saturating_sub(1) {
self.selected_field += 1;
}
false
},
KeyCode::Enter => {
self.start_editing();
false
},
KeyCode::Tab => {
self.next_field();
false
},
KeyCode::Char(' ') => {
self.toggle_boolean_field();
false
},
KeyCode::Char('s') | KeyCode::F(10) => {
self.submit_form()
},
_ => false,
}
}
/// Handle text input in editing mode
fn handle_text_input(&mut self, key: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
match key {
KeyCode::Enter => {
self.finish_editing();
false
},
KeyCode::Esc => {
self.cancel_editing();
false
},
KeyCode::Char(c) => {
self.input_buffer.push(c);
false
},
KeyCode::Backspace => {
self.input_buffer.pop();
false
},
KeyCode::Tab => {
self.finish_editing();
self.next_field();
false
},
_ => false,
}
}
/// Start editing the current field
fn start_editing(&mut self) {
if let Some(field) = self.fields.get(self.selected_field) {
match &field.field_type {
ConfigField::Text { value, .. } => {
self.input_buffer = value.clone();
self.input_mode = InputMode::Editing;
},
ConfigField::Number { value, .. } => {
self.input_buffer = value.to_string();
self.input_mode = InputMode::Editing;
},
ConfigField::Select { value, options } => {
// Cycle through options for select fields
if let Some(current_idx) = options.iter().position(|o| o == value) {
let next_idx = (current_idx + 1) % options.len();
self.set_field_value(&field.name, &options[next_idx]);
}
},
_ => {}, // Boolean fields don't need editing mode
}
}
}
/// Finish editing and update the field
fn finish_editing(&mut self) {
if self.input_mode == InputMode::Editing {
if let Some(field) = self.fields.get(self.selected_field) {
let field_name = field.name.clone();
self.set_field_value(&field_name, &self.input_buffer);
}
}
self.input_mode = InputMode::Normal;
self.input_buffer.clear();
}
/// Cancel editing and revert to original value
fn cancel_editing(&mut self) {
self.input_mode = InputMode::Normal;
self.input_buffer.clear();
}
/// Move to next field
fn next_field(&mut self) {
self.selected_field = (self.selected_field + 1) % self.fields.len();
}
/// Toggle boolean field value
fn toggle_boolean_field(&mut self) {
if let Some(field) = self.fields.get(self.selected_field) {
if let ConfigField::Boolean { value } = &field.field_type {
let field_name = field.name.clone();
self.set_field_value(&field_name, &(!value).to_string());
}
}
}
/// Set field value and validate
fn set_field_value(&mut self, field_name: &str, value: &str) {
if let Some(field) = self.fields.iter_mut().find(|f| f.name == field_name) {
match &mut field.field_type {
ConfigField::Text { value: field_value, .. } => {
*field_value = value.to_string();
},
ConfigField::Number { value: field_value, min, max } => {
if let Ok(num) = value.parse::<f64>() {
*field_value = num.clamp(*min, *max);
}
},
ConfigField::Boolean { value: field_value } => {
if let Ok(bool_val) = value.parse::<bool>() {
*field_value = bool_val;
}
},
ConfigField::Select { value: field_value, options } => {
if options.contains(&value.to_string()) {
*field_value = value.to_string();
}
},
}
// Validate the field
self.validate_field(field);
}
self.validate_form();
}
/// Validate a single field
fn validate_field(&mut self, field: &mut FormField) {
field.validation_error = None;
// Check required fields
if field.required {
let is_empty = match &field.field_type {
ConfigField::Text { value, .. } => value.is_empty(),
ConfigField::Select { value, .. } => value.is_empty(),
_ => false,
};
if is_empty {
field.validation_error = Some("This field is required".to_string());
return;
}
}
// Check numeric ranges
if let ConfigField::Number { value, min, max } = &field.field_type {
if *value < *min || *value > *max {
field.validation_error = Some(format!("Value must be between {} and {}", min, max));
return;
}
}
// Run custom validators
if let Some(validator) = self.validators.get(&field.name) {
let field_value = match &field.field_type {
ConfigField::Text { value, .. } => value.clone(),
ConfigField::Number { value, .. } => value.to_string(),
ConfigField::Boolean { value } => value.to_string(),
ConfigField::Select { value, .. } => value.clone(),
};
if let Err(error) = validator(&field_value) {
field.validation_error = Some(error);
}
}
}
/// Validate the entire form
fn validate_form(&mut self) {
let mut errors = HashMap::new();
let mut is_valid = true;
for field in &self.fields {
if let Some(ref error) = field.validation_error {
errors.insert(field.name.clone(), error.clone());
is_valid = false;
}
}
self.validation = ValidationResult { is_valid, errors };
self.can_submit = is_valid;
}
/// Submit the form
fn submit_form(&mut self) -> bool {
self.validate_form();
self.can_submit && self.validation.is_valid
}
/// Get field value as string
pub fn get_field_value(&self, field_name: &str) -> Option<String> {
self.fields.iter()
.find(|f| f.name == field_name)
.map(|field| match &field.field_type {
ConfigField::Text { value, .. } => value.clone(),
ConfigField::Number { value, .. } => value.to_string(),
ConfigField::Boolean { value } => value.to_string(),
ConfigField::Select { value, .. } => value.clone(),
})
}
/// Get all form values
pub fn get_all_values(&self) -> HashMap<String, String> {
self.fields.iter()
.map(|field| {
let value = match &field.field_type {
ConfigField::Text { value, .. } => value.clone(),
ConfigField::Number { value, .. } => value.to_string(),
ConfigField::Boolean { value } => value.to_string(),
ConfigField::Select { value, .. } => value.clone(),
};
(field.name.clone(), value)
})
.collect()
}
/// Create field display text
fn format_field_display(&self, field: &FormField, is_selected: bool) -> String {
let field_display = match &field.field_type {
ConfigField::Text { value, placeholder } => {
if value.is_empty() {
format!("{}: [{}]", field.label, placeholder)
} else {
format!("{}: {}", field.label, value)
}
},
ConfigField::Number { value, min, max } => {
format!("{}: {} (range: {}-{})", field.label, value, min, max)
},
ConfigField::Boolean { value } => {
let checkbox = if *value { "" } else { "" };
format!("{} {}", checkbox, field.label)
},
ConfigField::Select { value, options } => {
format!("{}: {} ▼ ({})", field.label, value, options.len())
},
};
if is_selected && self.input_mode == InputMode::Editing {
format!("{}", field_display)
} else if is_selected {
format!("> {}", field_display)
} else {
format!(" {}", field_display)
}
}
}
impl FinancialWidget for ConfigForm {
type Data = Vec<FormField>;
fn update_data(&mut self, data: Self::Data) {
self.fields = data;
self.validate_form();
}
fn clear(&mut self) {
self.fields.clear();
self.input_mode = InputMode::Normal;
self.selected_field = 0;
self.input_buffer.clear();
self.validation = ValidationResult {
is_valid: true,
errors: HashMap::new(),
};
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
!self.fields.is_empty()
}
}
impl Widget for ConfigForm {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
if !self.has_data() {
let no_data = ratatui::widgets::Paragraph::new("No form fields")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
}
// Form status area
let status_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let mode_text = match self.input_mode {
InputMode::Normal => "Navigate: ↑↓ Select: Enter Space: Toggle Submit: S",
InputMode::Editing => "Editing... Enter: Confirm Esc: Cancel",
};
let status_color = if self.can_submit {
self.colors.profit
} else {
self.colors.warning
};
let status = Paragraph::new(mode_text)
.style(Style::default().fg(status_color));
status.render(status_area, buf);
// Fields area
let fields_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
// Create field list items
let mut items = Vec::new();
for (index, field) in self.fields.into_iter().enumerate() {
let is_selected = index == self.selected_field;
let field_text = self.format_field_display(field, is_selected);
let style = if is_selected {
Style::default().fg(self.colors.text).bg(Color::DarkGray)
} else {
Style::default().fg(self.colors.text)
};
let mut item = ListItem::new(field_text).style(style);
// Add validation error if present and inline errors are enabled
if self.show_inline_errors {
if let Some(ref error) = field.validation_error {
let error_text = format!("{}", error);
item = ListItem::new(vec![
Line::from(field_text).style(style),
Line::from(error_text).style(Style::default().fg(self.colors.critical)),
]);
}
}
items.push(item);
}
let list = List::new(items)
.block(Block::default())
.style(Style::default().fg(self.colors.text));
list.render(fields_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
#[test]
fn test_config_form_creation() {
let form = ConfigForm::new("Test Form");
assert_eq!(form.title(), "Test Form");
assert!(!form.has_data());
assert_eq!(form.input_mode, InputMode::Normal);
}
#[test]
fn test_add_fields() {
let form = ConfigForm::new("Test")
.add_text_field("name", "Name", "Enter name", true)
.add_number_field("age", "Age", 25.0, 0.0, 100.0, true)
.add_boolean_field("enabled", "Enabled", false)
.add_select_field("type", "Type", vec!["A".to_string(), "B".to_string()], None, true);
assert_eq!(form.fields.len(), 4);
assert!(form.has_data());
}
#[test]
fn test_field_validation() {
let mut form = ConfigForm::new("Test")
.add_text_field("required_field", "Required", "Enter value", true)
.add_number_field("number", "Number", 50.0, 0.0, 100.0, false);
// Test required field validation
form.set_field_value("required_field", "");
assert!(!form.validation.is_valid);
form.set_field_value("required_field", "test");
assert!(form.validation.is_valid);
// Test number range validation
form.set_field_value("number", "150.0");
if let Some(field) = form.fields.iter().find(|f| f.name == "number") {
if let ConfigField::Number { value, .. } = &field.field_type {
assert_eq!(*value, 100.0); // Should be clamped to max
}
}
}
#[test]
fn test_navigation() {
let mut form = ConfigForm::new("Test")
.add_text_field("field1", "Field 1", "", false)
.add_text_field("field2", "Field 2", "", false);
assert_eq!(form.selected_field, 0);
form.handle_input(KeyCode::Down);
assert_eq!(form.selected_field, 1);
form.handle_input(KeyCode::Up);
assert_eq!(form.selected_field, 0);
}
#[test]
fn test_editing_mode() {
let mut form = ConfigForm::new("Test")
.add_text_field("test", "Test Field", "placeholder", false);
assert_eq!(form.input_mode, InputMode::Normal);
form.handle_input(KeyCode::Enter);
assert_eq!(form.input_mode, InputMode::Editing);
form.handle_input(KeyCode::Char('h'));
form.handle_input(KeyCode::Char('i'));
assert_eq!(form.input_buffer, "hi");
form.handle_input(KeyCode::Enter);
assert_eq!(form.input_mode, InputMode::Normal);
assert_eq!(form.get_field_value("test"), Some("hi".to_string()));
}
#[test]
fn test_boolean_toggle() {
let mut form = ConfigForm::new("Test")
.add_boolean_field("toggle", "Toggle", false);
assert_eq!(form.get_field_value("toggle"), Some("false".to_string()));
form.handle_input(KeyCode::Char(' '));
assert_eq!(form.get_field_value("toggle"), Some("true".to_string()));
}
#[test]
fn test_get_all_values() {
let mut form = ConfigForm::new("Test")
.add_text_field("name", "Name", "", false)
.add_boolean_field("enabled", "Enabled", true);
form.set_field_value("name", "test_value");
let values = form.get_all_values();
assert_eq!(values.get("name"), Some(&"test_value".to_string()));
assert_eq!(values.get("enabled"), Some(&"true".to_string()));
}
#[test]
fn test_custom_validator() {
let form = ConfigForm::new("Test")
.add_text_field("email", "Email", "Enter email", true)
.add_validator("email", |value| {
if value.contains('@') {
Ok(())
} else {
Err("Invalid email format".to_string())
}
});
let mut form = form;
form.set_field_value("email", "invalid");
assert!(!form.validation.is_valid);
form.set_field_value("email", "test@example.com");
assert!(form.validation.is_valid);
}
}

View File

@@ -1,278 +0,0 @@
//! Custom Ratatui widgets for financial data visualization
//!
//! This module provides specialized widgets for the Foxhunt HFT trading terminal:
//! - Real-time candlestick charts with OHLC data
//! - Order book visualization with bid/ask spreads
//! - P&L heatmaps and sparklines for performance tracking
//! - Risk gauge widgets with color-coded status indicators
//! - Configuration forms with input validation
//!
//! All widgets are optimized for high-frequency updates and minimal screen flicker,
//! supporting mouse and keyboard interactions where appropriate.
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget},
symbols::DOT,
};
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use adaptive_strategy::microstructure::OrderLevel;
pub mod candlestick_chart;
pub mod order_book;
pub mod pnl_heatmap;
pub mod risk_gauge;
pub mod config_form;
pub mod sparkline;
/// Common color scheme for financial widgets
#[derive(Debug, Clone)]
pub struct FinancialColors {
pub profit: Color,
pub loss: Color,
pub neutral: Color,
pub bid: Color,
pub ask: Color,
pub warning: Color,
pub critical: Color,
pub background: Color,
pub text: Color,
pub border: Color,
}
impl Default for FinancialColors {
fn default() -> Self {
Self {
profit: Color::Green,
loss: Color::Red,
neutral: Color::Yellow,
bid: Color::Cyan,
ask: Color::Magenta,
warning: Color::Yellow,
critical: Color::Red,
background: Color::Black,
text: Color::White,
border: Color::Gray,
}
}
}
/// Base trait for all financial widgets with real-time data updates
pub trait FinancialWidget {
type Data;
/// Update widget with new data
fn update_data(&mut self, data: Self::Data);
/// Clear all data from the widget
fn clear(&mut self);
/// Get the widget's title
fn title(&self) -> &str;
/// Check if widget has data to display
fn has_data(&self) -> bool;
}
/// Common data structures for financial widgets
/// OHLC (Open, High, Low, Close) candle data
#[derive(Debug, Clone)]
pub struct Candle {
pub timestamp: DateTime<Utc>,
pub open: Decimal,
pub high: Decimal,
pub low: Decimal,
pub close: Decimal,
pub volume: Decimal,
}
/// Order book snapshot with bids and asks
#[derive(Debug, Clone)]
pub struct OrderBookSnapshot {
pub timestamp: DateTime<Utc>,
pub bids: Vec<OrderLevel>,
pub asks: Vec<OrderLevel>,
pub spread: Decimal,
}
/// P&L data point for performance tracking
#[derive(Debug, Clone)]
pub struct PnlData {
pub timestamp: DateTime<Utc>,
pub realized_pnl: Decimal,
pub unrealized_pnl: Decimal,
pub total_pnl: Decimal,
pub strategy: String,
}
/// Risk metrics for gauge display
#[derive(Debug, Clone)]
pub struct RiskMetrics {
pub var_utilization: f64, // 0.0 to 1.0
pub position_utilization: f64, // 0.0 to 1.0
pub drawdown: Decimal,
pub sharpe_ratio: f64,
pub risk_level: RiskLevel,
}
/// Risk level classification
#[derive(Debug, Clone, PartialEq)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
pub fn color(&self, colors: &FinancialColors) -> Color {
match self {
RiskLevel::Low => colors.profit,
RiskLevel::Medium => colors.neutral,
RiskLevel::High => colors.warning,
RiskLevel::Critical => colors.critical,
}
}
}
/// Configuration field types for forms
#[derive(Debug, Clone)]
pub enum ConfigField {
Text { value: String, placeholder: String },
Number { value: f64, min: f64, max: f64 },
Boolean { value: bool },
Select { value: String, options: Vec<String> },
}
/// Configuration form field definition
#[derive(Debug, Clone)]
pub struct FormField {
pub name: String,
pub label: String,
pub field_type: ConfigField,
pub required: bool,
pub validation_error: Option<String>,
}
/// Helper functions for common widget operations
/// Format decimal for display with appropriate precision
pub fn format_price(price: Decimal, precision: u32) -> String {
format!("{:.precision$}", price, precision = precision as usize)
}
/// Format percentage with sign
pub fn format_percentage(value: f64) -> String {
if value >= 0.0 {
format!("+{:.2}%", value * 100.0)
} else {
format!("{:.2}%", value * 100.0)
}
}
/// Get color for price change
pub fn price_change_color(change: Decimal, colors: &FinancialColors) -> Color {
if change > Decimal::ZERO {
colors.profit
} else if change < Decimal::ZERO {
colors.loss
} else {
colors.neutral
}
}
/// Create bordered block for widgets
pub fn create_block(title: &str, colors: &FinancialColors) -> Block {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(colors.border))
.title_style(Style::default().fg(colors.text).add_modifier(Modifier::BOLD))
}
/// Utility for maintaining fixed-size data buffers
pub struct CircularBuffer<T> {
data: VecDeque<T>,
capacity: usize,
}
impl<T> CircularBuffer<T> {
pub fn new(capacity: usize) -> Self {
Self {
data: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, item: T) {
if self.data.len() >= self.capacity {
self.data.pop_front();
}
self.data.push_back(item);
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.data.iter()
}
pub fn len(&self) -> usize {
self.data.len()
}
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
pub fn clear(&mut self) {
self.data.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circular_buffer() {
let mut buffer: CircularBuffer<i32> = CircularBuffer::new(3);
buffer.push(1);
buffer.push(2);
buffer.push(3);
assert_eq!(buffer.len(), 3);
buffer.push(4);
assert_eq!(buffer.len(), 3);
let values: Vec<&i32> = buffer.iter().collect();
assert_eq!(values, vec![&2, &3, &4]);
}
#[test]
fn test_format_price() {
let price = Decimal::new(12345, 2); // 123.45
assert_eq!(format_price(price, 2), "123.45");
assert_eq!(format_price(price, 4), "123.4500");
}
#[test]
fn test_format_percentage() {
assert_eq!(format_percentage(0.1234), "+12.34%");
assert_eq!(format_percentage(-0.0567), "-5.67%");
assert_eq!(format_percentage(0.0), "+0.00%");
}
#[test]
fn test_risk_level_color() {
let colors = FinancialColors::default();
assert_eq!(RiskLevel::Low.color(&colors), Color::Green);
assert_eq!(RiskLevel::Medium.color(&colors), Color::Yellow);
assert_eq!(RiskLevel::High.color(&colors), Color::Yellow);
assert_eq!(RiskLevel::Critical.color(&colors), Color::Red);
}
}

View File

@@ -1,527 +0,0 @@
//! Order book visualization widget for market depth display
//!
//! Displays real-time order book data with:
//! - Bid/ask levels with price, size, and count
//! - Visual depth representation using bars
//! - Spread highlighting and calculation
//! - Size aggregation and formatting
//! - Real-time updates with minimal flicker
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget, Row, Table, Cell},
};
use std::cmp::Ordering;
use chrono::{DateTime, Utc};
use super::{
FinancialWidget, FinancialColors, OrderLevel, OrderBookSnapshot,
create_block, format_price
};
/// Order book visualization widget
#[derive(Debug)]
pub struct OrderBookWidget {
/// Widget title
title: String,
/// Current order book snapshot
order_book: Option<OrderBookSnapshot>,
/// Color scheme
colors: FinancialColors,
/// Number of levels to display (per side)
depth_levels: usize,
/// Price precision for display
price_precision: u32,
/// Size precision for display
size_precision: u32,
/// Show order count column
show_count: bool,
/// Show visual depth bars
show_depth_bars: bool,
/// Aggregate sizes by price level
aggregate_sizes: bool,
}
impl OrderBookWidget {
/// Create a new order book widget
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
order_book: None,
colors: FinancialColors::default(),
depth_levels: 10,
price_precision: 2,
size_precision: 0,
show_count: true,
show_depth_bars: true,
aggregate_sizes: true,
}
}
/// Set number of depth levels to display
pub fn with_depth_levels(mut self, levels: usize) -> Self {
self.depth_levels = levels;
self
}
/// Set price precision
pub fn with_price_precision(mut self, precision: u32) -> Self {
self.price_precision = precision;
self
}
/// Set size precision
pub fn with_size_precision(mut self, precision: u32) -> Self {
self.size_precision = precision;
self
}
/// Toggle order count display
pub fn with_count_display(mut self, show_count: bool) -> Self {
self.show_count = show_count;
self
}
/// Toggle depth bars display
pub fn with_depth_bars(mut self, show_bars: bool) -> Self {
self.show_depth_bars = show_bars;
self
}
/// Toggle size aggregation
pub fn with_aggregation(mut self, aggregate: bool) -> Self {
self.aggregate_sizes = aggregate;
self
}
/// Update order book data
pub fn update_order_book(&mut self, order_book: OrderBookSnapshot) {
self.order_book = Some(order_book);
}
/// Get current spread
pub fn spread(&self) -> Option<Decimal> {
self.order_book.as_ref().map(|ob| ob.spread)
}
/// Get best bid price
pub fn best_bid(&self) -> Option<Decimal> {
self.order_book.as_ref()
.and_then(|ob| ob.bids.first().map(|level| level.price))
}
/// Get best ask price
pub fn best_ask(&self) -> Option<Decimal> {
self.order_book.as_ref()
.and_then(|ob| ob.asks.first().map(|level| level.price))
}
/// Format size for display
fn format_size(&self, size: Decimal) -> String {
if size >= Decimal::new(1_000_000, 0) {
format!("{:.1}M", size / Decimal::new(1_000_000, 0))
} else if size >= Decimal::new(1_000, 0) {
format!("{:.1}K", size / Decimal::new(1_000, 0))
} else {
format!("{:.precision$}", size, precision = self.size_precision as usize)
}
}
/// Create depth bar representation
fn create_depth_bar(&self, size: Decimal, max_size: Decimal, width: usize) -> String {
if max_size == Decimal::ZERO {
return " ".repeat(width);
}
let ratio = (size / max_size).to_f64();
let bar_length = (ratio * width as f64) as usize;
let bar = "".repeat(bar_length);
let padding = " ".repeat(width.saturating_sub(bar_length));
format!("{}{}", bar, padding)
}
/// Aggregate order levels by price if enabled
fn aggregate_levels(&self, levels: &[OrderLevel]) -> Vec<OrderLevel> {
if !self.aggregate_sizes {
return levels.to_vec();
}
let mut aggregated = std::collections::HashMap::new();
for level in levels {
let entry = aggregated.entry(level.price).or_insert(OrderLevel {
price: level.price,
size: Decimal::ZERO,
count: 0,
});
entry.size += level.size;
entry.count += level.count;
}
let mut result: Vec<OrderLevel> = aggregated.into_values().collect();
result.sort_by(|a, b| b.price.cmp(&a.price)); // Sort descending by price
result
}
/// Create table rows for order book display
fn create_order_book_rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
if let Some(ref book) = self.order_book {
// Process asks (ascending price order for display)
let mut asks = self.aggregate_levels(&book.asks);
asks.sort_by(|a, b| a.price.cmp(&b.price));
let asks_display: Vec<&OrderLevel> = asks.iter()
.take(self.depth_levels)
.collect();
// Process bids (descending price order)
let mut bids = self.aggregate_levels(&book.bids);
bids.sort_by(|a, b| b.price.cmp(&a.price));
let bids_display: Vec<&OrderLevel> = bids.iter()
.take(self.depth_levels)
.collect();
// Find max size for depth bar scaling
let max_size = asks_display.iter()
.chain(bids_display.iter())
.map(|level| level.size)
.max()
.unwrap_or(Decimal::ZERO);
// Display asks (top to bottom, lowest to highest price)
for level in asks_display.into_iter().rev() {
let mut cells = vec![
Cell::from("").style(Style::default()), // Empty bid side
Cell::from("").style(Style::default()), // Empty bid size
];
if self.show_count {
cells.push(Cell::from("").style(Style::default())); // Empty bid count
}
// Price column (centered)
cells.push(
Cell::from(format_price(level.price, self.price_precision))
.style(Style::default().fg(self.colors.ask))
);
// Ask size
cells.push(
Cell::from(self.format_size(level.size))
.style(Style::default().fg(self.colors.ask))
);
if self.show_count {
cells.push(
Cell::from(level.count.to_string())
.style(Style::default().fg(self.colors.ask))
);
}
if self.show_depth_bars {
cells.push(
Cell::from(self.create_depth_bar(level.size, max_size, 10))
.style(Style::default().fg(self.colors.ask))
);
}
rows.push(Row::new(cells));
}
// Add spread row
if let (Some(best_bid), Some(best_ask)) = (self.best_bid(), self.best_ask()) {
let spread = best_ask - best_bid;
let spread_bps = if best_bid != Decimal::ZERO {
((spread / best_bid) * Decimal::new(10000, 0)).round()
} else {
Decimal::ZERO
};
let spread_text = format!("Spread: {} ({} bps)",
format_price(spread, self.price_precision),
spread_bps
);
let cell_count = if self.show_count { 7 } else { 5 };
let cell_count = if self.show_depth_bars { cell_count + 1 } else { cell_count };
let spread_row = Row::new(vec![
Cell::from(spread_text)
.style(Style::default().fg(self.colors.neutral).add_modifier(Modifier::BOLD));
cell_count
]);
rows.push(spread_row);
}
// Display bids (highest to lowest price)
for level in &bids_display {
let mut cells = vec![
Cell::from(format_price(level.price, self.price_precision))
.style(Style::default().fg(self.colors.bid)),
Cell::from(self.format_size(level.size))
.style(Style::default().fg(self.colors.bid)),
];
if self.show_count {
cells.push(
Cell::from(level.count.to_string())
.style(Style::default().fg(self.colors.bid))
);
}
// Empty ask columns
cells.push(Cell::from("").style(Style::default())); // Price (already filled by bid)
cells.push(Cell::from("").style(Style::default())); // Ask size
if self.show_count {
cells.push(Cell::from("").style(Style::default())); // Ask count
}
if self.show_depth_bars {
cells.insert(
if self.show_count { 3 } else { 2 },
Cell::from(self.create_depth_bar(level.size, max_size, 10))
.style(Style::default().fg(self.colors.bid))
);
cells.push(Cell::from("").style(Style::default())); // Empty ask depth bar
}
rows.push(Row::new(cells));
}
}
rows
}
/// Create table headers
fn create_headers(&self) -> Row {
let mut headers = vec!["Bid Price", "Bid Size"];
if self.show_count {
headers.push("Bid Count");
}
if self.show_depth_bars {
headers.push("Bid Depth");
}
headers.push("Price");
headers.push("Ask Size");
if self.show_count {
headers.push("Ask Count");
}
if self.show_depth_bars {
headers.push("Ask Depth");
}
Row::new(headers.into_iter().map(|h| {
Cell::from(h).style(Style::default().fg(self.colors.text).add_modifier(Modifier::BOLD))
}))
}
/// Calculate column widths
fn column_widths(&self) -> Vec<Constraint> {
let mut widths = vec![
Constraint::Length(12), // Bid Price
Constraint::Length(10), // Bid Size
];
if self.show_count {
widths.push(Constraint::Length(8)); // Bid Count
}
if self.show_depth_bars {
widths.push(Constraint::Length(12)); // Bid Depth
}
widths.push(Constraint::Length(12)); // Price (center)
widths.push(Constraint::Length(10)); // Ask Size
if self.show_count {
widths.push(Constraint::Length(8)); // Ask Count
}
if self.show_depth_bars {
widths.push(Constraint::Length(12)); // Ask Depth
}
widths
}
}
impl FinancialWidget for OrderBookWidget {
type Data = OrderBookSnapshot;
fn update_data(&mut self, data: Self::Data) {
self.order_book = Some(data);
}
fn clear(&mut self) {
self.order_book = None;
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
self.order_book.is_some()
}
}
impl Widget for OrderBookWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
if !self.has_data() {
let no_data = ratatui::widgets::Paragraph::new("No order book data")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
}
// Create status line
let status_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let mut status_text = String::new();
if let (Some(bid), Some(ask)) = (self.best_bid(), self.best_ask()) {
status_text = format!("Best: {} / {}",
format_price(bid, self.price_precision),
format_price(ask, self.price_precision)
);
if let Some(spread) = self.spread() {
status_text.push_str(&format!(" | Spread: {}",
format_price(spread, self.price_precision)
));
}
}
let status = ratatui::widgets::Paragraph::new(status_text)
.style(Style::default().fg(self.colors.text));
status.render(status_area, buf);
// Table area
let table_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
// Create and render table
let rows = self.create_order_book_rows();
let header = self.create_headers();
let widths = self.column_widths();
let table = Table::new(rows, widths)
.header(header)
.block(Block::default())
.style(Style::default().fg(self.colors.text))
.highlight_style(Style::default().bg(Color::DarkGray))
.column_spacing(1);
table.render(table_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_order_book() -> OrderBookSnapshot {
OrderBookSnapshot {
timestamp: Utc::now(),
bids: vec![
OrderLevel { price: Decimal::new(10000, 2), size: Decimal::new(100, 0), count: 5 },
OrderLevel { price: Decimal::new(9999, 2), size: Decimal::new(200, 0), count: 3 },
OrderLevel { price: Decimal::new(9998, 2), size: Decimal::new(150, 0), count: 2 },
],
asks: vec![
OrderLevel { price: Decimal::new(10001, 2), size: Decimal::new(120, 0), count: 4 },
OrderLevel { price: Decimal::new(10002, 2), size: Decimal::new(180, 0), count: 6 },
OrderLevel { price: Decimal::new(10003, 2), size: Decimal::new(90, 0), count: 1 },
],
spread: Decimal::new(1, 2), // 0.01
}
}
#[test]
fn test_order_book_widget_creation() {
let widget = OrderBookWidget::new("Test Order Book");
assert_eq!(widget.title(), "Test Order Book");
assert!(!widget.has_data());
}
#[test]
fn test_update_order_book() {
let mut widget = OrderBookWidget::new("Test");
let book = create_test_order_book();
widget.update_order_book(book);
assert!(widget.has_data());
}
#[test]
fn test_best_bid_ask() {
let mut widget = OrderBookWidget::new("Test");
let book = create_test_order_book();
widget.update_order_book(book);
assert_eq!(widget.best_bid(), Some(Decimal::new(10000, 2)));
assert_eq!(widget.best_ask(), Some(Decimal::new(10001, 2)));
assert_eq!(widget.spread(), Some(Decimal::new(1, 2)));
}
#[test]
fn test_format_size() {
let widget = OrderBookWidget::new("Test");
assert_eq!(widget.format_size(Decimal::new(500, 0)), "500");
assert_eq!(widget.format_size(Decimal::new(1500, 0)), "1.5K");
assert_eq!(widget.format_size(Decimal::new(2500000, 0)), "2.5M");
}
#[test]
fn test_depth_bar() {
let widget = OrderBookWidget::new("Test");
let max_size = Decimal::new(1000, 0);
let bar = widget.create_depth_bar(Decimal::new(500, 0), max_size, 10);
assert_eq!(bar.len(), 10);
assert!(bar.contains(""));
}
#[test]
fn test_aggregate_levels() {
let widget = OrderBookWidget::new("Test").with_aggregation(true);
let levels = vec![
OrderLevel { price: Decimal::new(100, 0), size: Decimal::new(50, 0), count: 1 },
OrderLevel { price: Decimal::new(100, 0), size: Decimal::new(30, 0), count: 2 },
OrderLevel { price: Decimal::new(101, 0), size: Decimal::new(25, 0), count: 1 },
];
let aggregated = widget.aggregate_levels(&levels);
assert_eq!(aggregated.len(), 2);
let level_100 = aggregated.iter().find(|l| l.price == Decimal::new(100, 0)).unwrap();
assert_eq!(level_100.size, Decimal::new(80, 0));
assert_eq!(level_100.count, 3);
}
}

View File

@@ -1,530 +0,0 @@
//! P&L heatmap widget for portfolio performance visualization
//!
//! Displays profit and loss data as a color-coded heatmap with:
//! - Strategy-based or time-based grouping
//! - Color intensity based on P&L magnitude
//! - Interactive selection and details
//! - Real-time updates with performance metrics
//! - Configurable color schemes and thresholds
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget, Cell, Row, Table},
};
use std::collections::HashMap;
use chrono::{DateTime, Utc, Duration, Timelike};
use super::{
FinancialWidget, FinancialColors, PnlData,
create_block, format_price, format_percentage
};
/// P&L heatmap grouping method
#[derive(Debug, Clone, PartialEq)]
pub enum HeatmapGrouping {
Strategy, // Group by trading strategy
TimeHourly, // Group by hour
TimeDaily, // Group by day
TimeWeekly, // Group by week
Instrument, // Group by trading instrument
}
/// Heatmap cell data
#[derive(Debug, Clone)]
pub struct HeatmapCell {
pub label: String,
pub value: Decimal,
pub count: u32,
pub percentage: f64,
pub last_update: DateTime<Utc>,
}
/// P&L heatmap widget
#[derive(Debug)]
pub struct PnlHeatmap {
/// Widget title
title: String,
/// P&L data points
data: Vec<PnlData>,
/// Color scheme
colors: FinancialColors,
/// Grouping method
grouping: HeatmapGrouping,
/// Show percentage values
show_percentage: bool,
/// Show trade counts
show_counts: bool,
/// Value precision
precision: u32,
/// Color intensity levels
intensity_levels: Vec<f64>,
/// Selected cell (for interaction)
selected_cell: Option<String>,
}
impl PnlHeatmap {
/// Create a new P&L heatmap widget
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
data: Vec::new(),
colors: FinancialColors::default(),
grouping: HeatmapGrouping::Strategy,
show_percentage: true,
show_counts: false,
precision: 2,
intensity_levels: vec![0.1, 0.25, 0.5, 0.75, 1.0],
selected_cell: None,
}
}
/// Set grouping method
pub fn with_grouping(mut self, grouping: HeatmapGrouping) -> Self {
self.grouping = grouping;
self
}
/// Toggle percentage display
pub fn with_percentage(mut self, show: bool) -> Self {
self.show_percentage = show;
self
}
/// Toggle count display
pub fn with_counts(mut self, show: bool) -> Self {
self.show_counts = show;
self
}
/// Set value precision
pub fn with_precision(mut self, precision: u32) -> Self {
self.precision = precision;
self
}
/// Set color intensity levels
pub fn with_intensity_levels(mut self, levels: Vec<f64>) -> Self {
self.intensity_levels = levels;
self
}
/// Add P&L data points
pub fn add_data(&mut self, pnl_data: Vec<PnlData>) {
self.data.extend(pnl_data);
}
/// Set selected cell for highlighting
pub fn select_cell(&mut self, label: Option<String>) {
self.selected_cell = label;
}
/// Group P&L data according to current grouping method
fn group_data(&self) -> HashMap<String, Vec<&PnlData>> {
let mut groups = HashMap::new();
for data in &self.data {
let key = match self.grouping {
HeatmapGrouping::Strategy => data.strategy.clone(),
HeatmapGrouping::TimeHourly => {
format!("{:02}:00", data.timestamp.hour())
},
HeatmapGrouping::TimeDaily => {
data.timestamp.format("%Y-%m-%d").to_string()
},
HeatmapGrouping::TimeWeekly => {
let week_start = data.timestamp.date_naive()
- Duration::days(data.timestamp.weekday().num_days_from_monday() as i64);
format!("Week {}", week_start.format("%Y-%m-%d"))
},
HeatmapGrouping::Instrument => {
// Extract instrument from strategy name if available
data.strategy.split('_').next().unwrap_or(&data.strategy).to_string()
},
};
groups.entry(key).or_insert_with(Vec::new).push(data);
}
groups
}
/// Calculate aggregated metrics for a group
fn calculate_group_metrics(&self, group_data: &[&PnlData]) -> HeatmapCell {
if group_data.is_empty() {
return HeatmapCell {
label: "Empty".to_string(),
value: Decimal::ZERO,
count: 0,
percentage: 0.0,
last_update: Utc::now(),
};
}
let total_pnl: Decimal = group_data.iter()
.map(|d| d.total_pnl)
.sum();
let total_capital: Decimal = group_data.iter()
.map(|d| d.realized_pnl.abs() + d.unrealized_pnl.abs())
.sum();
let percentage = if total_capital != Decimal::ZERO {
(total_pnl / total_capital).to_f64() * 100.0
} else {
0.0
};
let last_update = group_data.iter()
.map(|d| d.timestamp)
.max()
.unwrap_or_else(Utc::now);
HeatmapCell {
label: format!("Group ({})", group_data.len()),
value: total_pnl,
count: group_data.len() as u32,
percentage,
last_update,
}
}
/// Get color for P&L value based on intensity
fn get_pnl_color(&self, value: Decimal, max_abs_value: Decimal) -> Color {
if value == Decimal::ZERO {
return Color::Gray;
}
let intensity = if max_abs_value != Decimal::ZERO {
(value.abs() / max_abs_value).to_f64()
} else {
0.0
};
let base_color = if value > Decimal::ZERO {
self.colors.profit
} else {
self.colors.loss
};
// Adjust color intensity based on magnitude
match intensity {
i if i <= 0.2 => Color::DarkGray,
i if i <= 0.4 => self.dim_color(base_color, 0.6),
i if i <= 0.6 => self.dim_color(base_color, 0.8),
i if i <= 0.8 => base_color,
_ => self.brighten_color(base_color),
}
}
/// Dim a color for lower intensity
fn dim_color(&self, color: Color, factor: f32) -> Color {
match color {
Color::Red => Color::Rgb(
(255.0 * factor) as u8,
0,
0
),
Color::Green => Color::Rgb(
0,
(255.0 * factor) as u8,
0
),
_ => color,
}
}
/// Brighten a color for higher intensity
fn brighten_color(&self, color: Color) -> Color {
match color {
Color::Red => Color::LightRed,
Color::Green => Color::LightGreen,
Color::Yellow => Color::LightYellow,
_ => color,
}
}
/// Create table rows for heatmap display
fn create_heatmap_rows(&self) -> (Vec<Row>, Vec<Constraint>) {
let groups = self.group_data();
if groups.is_empty() {
return (Vec::new(), Vec::new());
}
let mut cells_data: Vec<(String, HeatmapCell)> = groups.into_iter()
.map(|(key, group)| (key.clone(), self.calculate_group_metrics(&group)))
.collect();
// Sort by P&L value (descending)
cells_data.sort_by(|a, b| b.1.value.cmp(&a.1.value));
// Find max absolute value for color scaling
let max_abs_value = cells_data.iter()
.map(|(_, cell)| cell.value.abs())
.max()
.unwrap_or(Decimal::ZERO);
let mut rows = Vec::new();
// Group into rows (e.g., 4 columns per row)
let cols_per_row = 4;
for chunk in cells_data.chunks(cols_per_row) {
let mut row_cells = Vec::new();
for (key, cell) in chunk {
let bg_color = self.get_pnl_color(cell.value, max_abs_value);
let text_color = if matches!(bg_color, Color::DarkGray | Color::Gray) {
Color::White
} else {
Color::Black
};
let is_selected = self.selected_cell.as_ref()
.map(|selected| selected == key)
.unwrap_or(false);
let mut cell_text = format!("{}\n{}",
key,
format_price(cell.value, self.precision)
);
if self.show_percentage {
cell_text.push_str(&format!("\n{}", format_percentage(cell.percentage / 100.0)));
}
if self.show_counts {
cell_text.push_str(&format!("\n({} trades)", cell.count));
}
let style = Style::default()
.bg(bg_color)
.fg(text_color);
let style = if is_selected {
style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
style
};
row_cells.push(Cell::from(cell_text).style(style));
}
// Fill remaining columns if needed
while row_cells.len() < cols_per_row {
row_cells.push(Cell::from("").style(Style::default()));
}
rows.push(Row::new(row_cells).height(if self.show_counts { 4 } else { 3 }));
}
let constraints = vec![Constraint::Percentage(25); cols_per_row];
(rows, constraints)
}
/// Create summary statistics
fn create_summary(&self) -> String {
if self.data.is_empty() {
return "No data available".to_string();
}
let total_pnl: Decimal = self.data.iter().map(|d| d.total_pnl).sum();
let realized_pnl: Decimal = self.data.iter().map(|d| d.realized_pnl).sum();
let unrealized_pnl: Decimal = self.data.iter().map(|d| d.unrealized_pnl).sum();
let profitable_count = self.data.iter()
.filter(|d| d.total_pnl > Decimal::ZERO)
.count();
let win_rate = if !self.data.is_empty() {
(profitable_count as f64 / self.data.len() as f64) * 100.0
} else {
0.0
};
format!(
"Total: {} | Realized: {} | Unrealized: {} | Win Rate: {:.1}% ({}/{})",
format_price(total_pnl, self.precision),
format_price(realized_pnl, self.precision),
format_price(unrealized_pnl, self.precision),
win_rate,
profitable_count,
self.data.len()
)
}
}
impl FinancialWidget for PnlHeatmap {
type Data = Vec<PnlData>;
fn update_data(&mut self, data: Self::Data) {
self.data = data;
}
fn clear(&mut self) {
self.data.clear();
self.selected_cell = None;
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
!self.data.is_empty()
}
}
impl Widget for PnlHeatmap {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
if !self.has_data() {
let no_data = ratatui::widgets::Paragraph::new("No P&L data")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
}
// Summary area
let summary_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let summary_text = self.create_summary();
let summary = ratatui::widgets::Paragraph::new(summary_text)
.style(Style::default().fg(self.colors.text))
.wrap(ratatui::widgets::Wrap { trim: true });
summary.render(summary_area, buf);
// Heatmap area
let heatmap_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
let (rows, constraints) = self.create_heatmap_rows();
if !rows.is_empty() {
let table = Table::new(rows, constraints)
.block(Block::default())
.style(Style::default())
.column_spacing(1);
table.render(heatmap_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_pnl_data() -> Vec<PnlData> {
vec![
PnlData {
timestamp: Utc::now(),
realized_pnl: Decimal::new(100, 0),
unrealized_pnl: Decimal::new(50, 0),
total_pnl: Decimal::new(150, 0),
strategy: "Strategy A".to_string(),
},
PnlData {
timestamp: Utc::now(),
realized_pnl: Decimal::new(-80, 0),
unrealized_pnl: Decimal::new(20, 0),
total_pnl: Decimal::new(-60, 0),
strategy: "Strategy B".to_string(),
},
PnlData {
timestamp: Utc::now(),
realized_pnl: Decimal::new(200, 0),
unrealized_pnl: Decimal::new(-30, 0),
total_pnl: Decimal::new(170, 0),
strategy: "Strategy A".to_string(),
},
]
}
#[test]
fn test_pnl_heatmap_creation() {
let heatmap = PnlHeatmap::new("Test Heatmap");
assert_eq!(heatmap.title(), "Test Heatmap");
assert!(!heatmap.has_data());
}
#[test]
fn test_add_data() {
let mut heatmap = PnlHeatmap::new("Test");
let data = create_test_pnl_data();
heatmap.add_data(data);
assert!(heatmap.has_data());
assert_eq!(heatmap.data.len(), 3);
}
#[test]
fn test_group_data_by_strategy() {
let mut heatmap = PnlHeatmap::new("Test").with_grouping(HeatmapGrouping::Strategy);
let data = create_test_pnl_data();
heatmap.add_data(data);
let groups = heatmap.group_data();
assert_eq!(groups.len(), 2); // Strategy A and Strategy B
assert_eq!(groups["Strategy A"].len(), 2);
assert_eq!(groups["Strategy B"].len(), 1);
}
#[test]
fn test_calculate_group_metrics() {
let heatmap = PnlHeatmap::new("Test");
let data = create_test_pnl_data();
let strategy_a_data: Vec<&PnlData> = data.iter()
.filter(|d| d.strategy == "Strategy A")
.collect();
let metrics = heatmap.calculate_group_metrics(&strategy_a_data);
assert_eq!(metrics.value, Decimal::new(320, 0)); // 150 + 170
assert_eq!(metrics.count, 2);
}
#[test]
fn test_color_calculation() {
let heatmap = PnlHeatmap::new("Test");
let max_value = Decimal::new(1000, 0);
let positive_color = heatmap.get_pnl_color(Decimal::new(500, 0), max_value);
let negative_color = heatmap.get_pnl_color(Decimal::new(-500, 0), max_value);
let zero_color = heatmap.get_pnl_color(Decimal::ZERO, max_value);
assert_ne!(positive_color, negative_color);
assert_eq!(zero_color, Color::Gray);
}
#[test]
fn test_update_data() {
let mut heatmap = PnlHeatmap::new("Test");
let data = create_test_pnl_data();
heatmap.update_data(data);
assert_eq!(heatmap.data.len(), 3);
}
#[test]
fn test_selected_cell() {
let mut heatmap = PnlHeatmap::new("Test");
heatmap.select_cell(Some("Strategy A".to_string()));
assert_eq!(heatmap.selected_cell, Some("Strategy A".to_string()));
heatmap.select_cell(None);
assert_eq!(heatmap.selected_cell, None);
}
}

View File

@@ -1,502 +0,0 @@
//! Risk gauge widget for real-time risk monitoring
//!
//! Displays risk metrics as circular gauges with:
//! - VaR utilization percentage
//! - Position size utilization
//! - Drawdown indicators
//! - Color-coded risk levels
//! - Threshold warnings and alerts
//! - Historical risk trends
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget, Gauge, LineGauge},
symbols::DOT,
};
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use num_traits::ToPrimitive;
use super::{
FinancialWidget, FinancialColors, RiskMetrics, RiskLevel,
create_block, format_price, format_percentage
};
/// Risk gauge display style
#[derive(Debug, Clone, PartialEq)]
pub enum GaugeStyle {
Circular, // Circular gauge (full circle)
Semicircular, // Half-circle gauge
Linear, // Linear progress bar
Compact, // Minimal linear display
}
/// Individual risk metric gauge
#[derive(Debug, Clone)]
pub struct RiskGauge {
/// Widget title
title: String,
/// Current risk metrics
metrics: Option<RiskMetrics>,
/// Color scheme
colors: FinancialColors,
/// Gauge display style
style: GaugeStyle,
/// Risk thresholds for color coding
thresholds: RiskThresholds,
/// Show percentage labels
show_labels: bool,
/// Show trend indicators
show_trends: bool,
/// Historical data for trend analysis
history: VecDeque<RiskMetrics>,
/// Maximum history length
max_history: usize,
}
/// Risk level thresholds
#[derive(Debug, Clone)]
pub struct RiskThresholds {
/// Low risk threshold (green)
pub low: f64,
/// Medium risk threshold (yellow)
pub medium: f64,
/// High risk threshold (orange)
pub high: f64,
/// Critical risk threshold (red)
pub critical: f64,
}
impl Default for RiskThresholds {
fn default() -> Self {
Self {
low: 0.25, // 25%
medium: 0.50, // 50%
high: 0.75, // 75%
critical: 0.90, // 90%
}
}
}
impl RiskGauge {
/// Create a new risk gauge widget
pub fn new(title: &str) -> Self {
Self {
title: title.to_string(),
metrics: None,
colors: FinancialColors::default(),
style: GaugeStyle::Semicircular,
thresholds: RiskThresholds::default(),
show_labels: true,
show_trends: false,
history: VecDeque::new(),
max_history: 100,
}
}
/// Set gauge display style
pub fn with_style(mut self, style: GaugeStyle) -> Self {
self.style = style;
self
}
/// Set risk thresholds
pub fn with_thresholds(mut self, thresholds: RiskThresholds) -> Self {
self.thresholds = thresholds;
self
}
/// Toggle percentage labels
pub fn with_labels(mut self, show: bool) -> Self {
self.show_labels = show;
self
}
/// Toggle trend indicators
pub fn with_trends(mut self, show: bool) -> Self {
self.show_trends = show;
self
}
/// Set maximum history length
pub fn with_history_length(mut self, length: usize) -> Self {
self.max_history = length;
self
}
/// Update risk metrics
pub fn update_metrics(&mut self, metrics: RiskMetrics) {
// Add to history
if self.history.len() >= self.max_history {
self.history.pop_front();
}
self.history.push_back(metrics.clone());
self.metrics = Some(metrics);
}
/// Get current risk level
pub fn current_risk_level(&self) -> RiskLevel {
self.metrics.as_ref()
.map(|m| m.risk_level.clone())
.unwrap_or(RiskLevel::Low)
}
/// Get risk level based on utilization
fn risk_level_from_utilization(&self, utilization: f64) -> RiskLevel {
if utilization >= self.thresholds.critical {
RiskLevel::Critical
} else if utilization >= self.thresholds.high {
RiskLevel::High
} else if utilization >= self.thresholds.medium {
RiskLevel::Medium
} else {
RiskLevel::Low
}
}
/// Get color for utilization level
fn color_for_utilization(&self, utilization: f64) -> Color {
let level = self.risk_level_from_utilization(utilization);
level.color(&self.colors)
}
/// Get trend indicator for a metric
fn get_trend(&self, current: f64, metric_extractor: fn(&RiskMetrics) -> f64) -> Option<&'static str> {
if !self.show_trends || self.history.len() < 2 {
return None;
}
let previous = self.history.get(self.history.len() - 2)
.map(metric_extractor)
.unwrap_or(current);
if current > previous * 1.05 {
Some("")
} else if current < previous * 0.95 {
Some("")
} else {
Some("")
}
}
/// Create gauge widget for a specific metric
fn create_gauge_widget(&self, title: &str, value: f64, area: Rect) -> impl Widget {
let percentage = (value * 100.0).min(100.0).max(0.0) as u16;
let color = self.color_for_utilization(value);
let label = if self.show_labels {
if let Some(trend) = self.get_trend(value, |_| value) {
format!("{} {:.1}% {}", title, value * 100.0, trend)
} else {
format!("{} {:.1}%", title, value * 100.0)
}
} else {
title.to_string()
};
match self.style {
GaugeStyle::Circular | GaugeStyle::Semicircular => {
Gauge::default()
.block(Block::default().title(label).borders(Borders::ALL))
.gauge_style(Style::default().fg(color))
.percent(percentage)
.use_unicode(true)
},
GaugeStyle::Linear => {
LineGauge::default()
.block(Block::default().title(label).borders(Borders::ALL))
.gauge_style(Style::default().fg(color))
.line_set(symbols::line::THICK)
.ratio(value)
},
GaugeStyle::Compact => {
LineGauge::default()
.block(Block::default().title(label))
.gauge_style(Style::default().fg(color))
.line_set(symbols::line::NORMAL)
.ratio(value)
},
}
}
/// Create status summary
fn create_status_summary(&self) -> String {
if let Some(ref metrics) = self.metrics {
let var_pct = metrics.var_utilization * 100.0;
let pos_pct = metrics.position_utilization * 100.0;
let risk_text = match metrics.risk_level {
RiskLevel::Low => "LOW",
RiskLevel::Medium => "MEDIUM",
RiskLevel::High => "HIGH",
RiskLevel::Critical => "CRITICAL",
};
format!(
"VaR: {:.1}% | Pos: {:.1}% | DD: {} | Sharpe: {:.2} | Risk: {}",
var_pct,
pos_pct,
format_price(metrics.drawdown, 2),
metrics.sharpe_ratio,
risk_text
)
} else {
"No risk data available".to_string()
}
}
/// Calculate layout for multiple gauges
fn calculate_gauge_layout(&self, area: Rect) -> (Rect, Rect, Rect, Rect) {
match self.style {
GaugeStyle::Compact => {
// Stack gauges vertically for compact display
let height_per_gauge = area.height / 4;
(
Rect { x: area.x, y: area.y, width: area.width, height: height_per_gauge },
Rect { x: area.x, y: area.y + height_per_gauge, width: area.width, height: height_per_gauge },
Rect { x: area.x, y: area.y + 2 * height_per_gauge, width: area.width, height: height_per_gauge },
Rect { x: area.x, y: area.y + 3 * height_per_gauge, width: area.width, height: height_per_gauge },
)
},
_ => {
// 2x2 grid for other styles
let width_half = area.width / 2;
let height_half = area.height / 2;
(
Rect { x: area.x, y: area.y, width: width_half, height: height_half },
Rect { x: area.x + width_half, y: area.y, width: width_half, height: height_half },
Rect { x: area.x, y: area.y + height_half, width: width_half, height: height_half },
Rect { x: area.x + width_half, y: area.y + height_half, width: width_half, height: height_half },
)
}
}
}
}
impl FinancialWidget for RiskGauge {
type Data = RiskMetrics;
fn update_data(&mut self, data: Self::Data) {
self.update_metrics(data);
}
fn clear(&mut self) {
self.metrics = None;
self.history.clear();
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
self.metrics.is_some()
}
}
impl Widget for RiskGauge {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
let metrics = match self.metrics.as_ref() {
Some(m) => m,
None => {
let no_data = ratatui::widgets::Paragraph::new("No risk data")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
},
};
// Status summary area
let status_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
let status_text = self.create_status_summary();
let status = ratatui::widgets::Paragraph::new(status_text)
.style(Style::default().fg(self.colors.text))
.wrap(ratatui::widgets::Wrap { trim: true });
status.render(status_area, buf);
// Gauges area
let gauges_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: inner.height.saturating_sub(1),
};
if gauges_area.height == 0 {
return;
}
let (var_area, pos_area, dd_area, sharpe_area) = self.calculate_gauge_layout(gauges_area);
// VaR Utilization Gauge
if var_area.width > 0 && var_area.height > 0 {
let var_gauge = self.create_gauge_widget("VaR", metrics.var_utilization, var_area);
var_gauge.render(var_area, buf);
}
// Position Utilization Gauge
if pos_area.width > 0 && pos_area.height > 0 {
let pos_gauge = self.create_gauge_widget("Position", metrics.position_utilization, pos_area);
pos_gauge.render(pos_area, buf);
}
// Drawdown Gauge (as percentage of max acceptable)
if dd_area.width > 0 && dd_area.height > 0 {
let dd_ratio = if metrics.drawdown.abs() <= Decimal::new(1000, 0) {
ToPrimitive::to_f64(&(metrics.drawdown.abs() / Decimal::new(1000, 0))).unwrap_or(0.0)
} else {
1.0
};
let dd_gauge = self.create_gauge_widget("Drawdown", dd_ratio, dd_area);
dd_gauge.render(dd_area, buf);
}
// Sharpe Ratio Gauge (normalized to 0-1, where 1.0 Sharpe = 50% of gauge)
if sharpe_area.width > 0 && sharpe_area.height > 0 {
let sharpe_ratio = (metrics.sharpe_ratio / 2.0).min(1.0).max(0.0);
let sharpe_gauge = self.create_gauge_widget("Sharpe", sharpe_ratio, sharpe_area);
sharpe_gauge.render(sharpe_area, buf);
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn create_test_risk_metrics() -> RiskMetrics {
RiskMetrics {
var_utilization: 0.65,
position_utilization: 0.45,
drawdown: Decimal::new(-250, 0),
sharpe_ratio: 1.25,
risk_level: RiskLevel::Medium,
}
}
#[test]
fn test_risk_gauge_creation() {
let gauge = RiskGauge::new("Test Risk Gauge");
assert_eq!(gauge.title(), "Test Risk Gauge");
assert!(!gauge.has_data());
assert_eq!(gauge.current_risk_level(), RiskLevel::Low);
}
#[test]
fn test_update_metrics() {
let mut gauge = RiskGauge::new("Test");
let metrics = create_test_risk_metrics();
gauge.update_metrics(metrics.clone());
assert!(gauge.has_data());
assert_eq!(gauge.current_risk_level(), RiskLevel::Medium);
assert_eq!(gauge.history.len(), 1);
}
#[test]
fn test_risk_level_from_utilization() {
let gauge = RiskGauge::new("Test");
assert_eq!(gauge.risk_level_from_utilization(0.1), RiskLevel::Low);
assert_eq!(gauge.risk_level_from_utilization(0.4), RiskLevel::Medium);
assert_eq!(gauge.risk_level_from_utilization(0.8), RiskLevel::High);
assert_eq!(gauge.risk_level_from_utilization(0.95), RiskLevel::Critical);
}
#[test]
fn test_color_for_utilization() {
let gauge = RiskGauge::new("Test");
let colors = FinancialColors::default();
assert_eq!(gauge.color_for_utilization(0.1), colors.profit);
assert_eq!(gauge.color_for_utilization(0.4), colors.neutral);
assert_eq!(gauge.color_for_utilization(0.8), colors.warning);
assert_eq!(gauge.color_for_utilization(0.95), colors.critical);
}
#[test]
fn test_trend_calculation() {
let mut gauge = RiskGauge::new("Test").with_trends(true);
// Add first metric
let metrics1 = RiskMetrics {
var_utilization: 0.5,
position_utilization: 0.3,
drawdown: Decimal::new(-100, 0),
sharpe_ratio: 1.0,
risk_level: RiskLevel::Medium,
};
gauge.update_metrics(metrics1);
// Add second metric (higher utilization)
let metrics2 = RiskMetrics {
var_utilization: 0.6,
position_utilization: 0.35,
drawdown: Decimal::new(-120, 0),
sharpe_ratio: 1.1,
risk_level: RiskLevel::Medium,
};
gauge.update_metrics(metrics2);
let trend = gauge.get_trend(0.6, |m| m.var_utilization);
assert_eq!(trend, Some(""));
}
#[test]
fn test_custom_thresholds() {
let thresholds = RiskThresholds {
low: 0.2,
medium: 0.4,
high: 0.6,
critical: 0.8,
};
let gauge = RiskGauge::new("Test").with_thresholds(thresholds);
assert_eq!(gauge.risk_level_from_utilization(0.3), RiskLevel::Medium);
assert_eq!(gauge.risk_level_from_utilization(0.7), RiskLevel::High);
}
#[test]
fn test_history_management() {
let mut gauge = RiskGauge::new("Test").with_history_length(3);
let metrics = create_test_risk_metrics();
// Add more metrics than max history
for i in 0..5 {
let mut m = metrics.clone();
m.var_utilization = 0.1 * i as f64;
gauge.update_metrics(m);
}
assert_eq!(gauge.history.len(), 3);
assert_eq!(gauge.history[0].var_utilization, 0.2);
assert_eq!(gauge.history[2].var_utilization, 0.4);
}
#[test]
fn test_status_summary() {
let mut gauge = RiskGauge::new("Test");
let metrics = create_test_risk_metrics();
gauge.update_metrics(metrics);
let summary = gauge.create_status_summary();
assert!(summary.contains("VaR: 65.0%"));
assert!(summary.contains("Pos: 45.0%"));
assert!(summary.contains("Sharpe: 1.25"));
assert!(summary.contains("Risk: MEDIUM"));
}
}

View File

@@ -1,433 +0,0 @@
//! Sparkline widget for compact time series visualization
//!
//! Provides minimal chart display for:
//! - P&L trends over time
//! - Price movements
//! - Performance metrics
//! - Volume patterns
//!
//! Optimized for real-time updates in small display areas.
use ratatui::{
prelude::*,
widgets::{Block, Borders, Widget, Sparkline as RatatuiSparkline},
};
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use super::{
FinancialWidget, FinancialColors, CircularBuffer,
create_block, format_price, price_change_color
};
/// Data point for sparkline display
#[derive(Debug, Clone)]
pub struct SparklineData {
pub timestamp: DateTime<Utc>,
pub value: Decimal,
pub label: Option<String>,
}
/// Compact sparkline widget for time series data
#[derive(Debug)]
pub struct Sparkline {
/// Widget title
title: String,
/// Data points buffer
data: CircularBuffer<SparklineData>,
/// Color scheme
colors: FinancialColors,
/// Data range for normalization
range: Option<(Decimal, Decimal)>,
/// Auto-scale data range
auto_scale: bool,
/// Show current value
show_current_value: bool,
/// Show min/max values
show_range: bool,
/// Value precision for display
precision: u32,
/// Sparkline style
style: SparklineStyle,
}
/// Sparkline visual style
#[derive(Debug, Clone, PartialEq)]
pub enum SparklineStyle {
Line,
Bar,
Filled,
}
impl Sparkline {
/// Create a new sparkline widget
pub fn new(title: &str, max_points: usize) -> Self {
Self {
title: title.to_string(),
data: CircularBuffer::new(max_points),
colors: FinancialColors::default(),
range: None,
auto_scale: true,
show_current_value: true,
show_range: false,
precision: 2,
style: SparklineStyle::Line,
}
}
/// Set data range for normalization
pub fn with_range(mut self, min: Decimal, max: Decimal) -> Self {
self.auto_scale = false;
self.range = Some((min, max));
self
}
/// Enable auto-scaling
pub fn with_auto_scale(mut self, auto_scale: bool) -> Self {
self.auto_scale = auto_scale;
if auto_scale {
self.range = None;
}
self
}
/// Show current value
pub fn with_current_value(mut self, show: bool) -> Self {
self.show_current_value = show;
self
}
/// Show min/max range
pub fn with_range_display(mut self, show: bool) -> Self {
self.show_range = show;
self
}
/// Set value precision
pub fn with_precision(mut self, precision: u32) -> Self {
self.precision = precision;
self
}
/// Set sparkline style
pub fn with_style(mut self, style: SparklineStyle) -> Self {
self.style = style;
self
}
/// Add a single data point
pub fn add_point(&mut self, data: SparklineData) {
self.data.push(data);
if self.auto_scale {
self.update_range();
}
}
/// Add multiple data points
pub fn add_points(&mut self, points: Vec<SparklineData>) {
for point in points {
self.data.push(point);
}
if self.auto_scale {
self.update_range();
}
}
/// Update data range based on current points
fn update_range(&mut self) {
if self.data.is_empty() {
self.range = None;
return;
}
let mut min_val = Decimal::MAX;
let mut max_val = Decimal::MIN;
for point in &self.data {
min_val = min_val.min(point.value);
max_val = max_val.max(point.value);
}
// Add 5% padding if min != max
if min_val != max_val {
let padding = (max_val - min_val) * Decimal::new(5, 2); // 0.05
self.range = Some((min_val - padding, max_val + padding));
} else {
// If all values are the same, create a small range around the value
let padding = if min_val == Decimal::ZERO {
Decimal::new(1, 0)
} else {
min_val.abs() * Decimal::new(1, 2) // 0.01
};
self.range = Some((min_val - padding, min_val + padding));
}
}
/// Get current (latest) value
pub fn current_value(&self) -> Option<Decimal> {
self.data.iter().last().map(|point| point.value)
}
/// Get value change from first to last point
pub fn value_change(&self) -> Option<(Decimal, Decimal)> {
let points: Vec<&SparklineData> = self.data.iter().collect();
if points.len() < 2 {
return None;
}
let first = &points[0];
let last = &points[points.len() - 1];
let change = last.value - first.value;
let percentage = if first.value != Decimal::ZERO {
(change / first.value) * Decimal::new(100, 0)
} else {
Decimal::ZERO
};
Some((change, percentage))
}
/// Convert data to u64 values for ratatui sparkline
fn normalize_data(&self) -> Vec<u64> {
if let Some((min_val, max_val)) = self.range {
let range = max_val - min_val;
if range == Decimal::ZERO {
return vec![50; self.data.len()]; // Middle value if no range
}
self.data.iter().map(|point| {
let normalized = (point.value - min_val) / range;
let scaled = normalized * Decimal::new(100, 0); // Scale to 0-100
scaled.to_u64().unwrap_or(0).min(100)
}).collect()
} else {
vec![0; self.data.len()]
}
}
/// Get display color based on trend
fn trend_color(&self) -> Color {
if let Some((change, _)) = self.value_change() {
price_change_color(change, &self.colors)
} else {
self.colors.neutral
}
}
/// Create status text with current value and trend
fn create_status_text(&self) -> String {
let mut status = String::new();
if let Some(current) = self.current_value() {
status.push_str(&format!("Current: {}", format_price(current, self.precision)));
if let Some((change, percentage)) = self.value_change() {
let sign = if change >= Decimal::ZERO { "+" } else { "" };
status.push_str(&format!(" ({}{}%)",
sign,
format_price(percentage, 2)
));
}
}
if self.show_range {
if let Some((min_val, max_val)) = self.range {
status.push_str(&format!(" Range: {} - {}",
format_price(min_val, self.precision),
format_price(max_val, self.precision)
));
}
}
status
}
}
impl FinancialWidget for Sparkline {
type Data = Vec<SparklineData>;
fn update_data(&mut self, data: Self::Data) {
self.data.clear();
for point in data {
self.data.push(point);
}
if self.auto_scale {
self.update_range();
}
}
fn clear(&mut self) {
self.data.clear();
if self.auto_scale {
self.range = None;
}
}
fn title(&self) -> &str {
&self.title
}
fn has_data(&self) -> bool {
!self.data.is_empty()
}
}
impl Widget for Sparkline {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = create_block(&self.title, &self.colors);
let inner = block.inner(area);
block.render(area, buf);
if !self.has_data() {
let no_data = ratatui::widgets::Paragraph::new("No data")
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
no_data.render(inner, buf);
return;
}
// Status area (if showing current value)
let (sparkline_area, status_area) = if self.show_current_value {
let status_height = 1;
(
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(status_height),
},
Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(status_height),
width: inner.width,
height: status_height,
}
)
} else {
(inner, Rect::default())
};
// Render sparkline
let data = self.normalize_data();
let trend_color = self.trend_color();
let sparkline = RatatuiSparkline::default()
.block(Block::default())
.data(&data)
.style(Style::default().fg(trend_color));
sparkline.render(sparkline_area, buf);
// Render status text
if self.show_current_value && status_area.height > 0 {
let status_text = self.create_status_text();
let status = ratatui::widgets::Paragraph::new(status_text)
.style(Style::default().fg(self.colors.text))
.alignment(Alignment::Center);
status.render(status_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn create_test_data(values: Vec<f64>) -> Vec<SparklineData> {
values.into_iter().enumerate().map(|(i, val)| {
SparklineData {
timestamp: Utc::now(),
value: Decimal::try_from(val).unwrap(),
label: Some(format!("Point {}", i)),
}
}).collect()
}
#[test]
fn test_sparkline_creation() {
let sparkline = Sparkline::new("Test Sparkline", 50);
assert_eq!(sparkline.title(), "Test Sparkline");
assert!(!sparkline.has_data());
}
#[test]
fn test_add_point() {
let mut sparkline = Sparkline::new("Test", 10);
let data = SparklineData {
timestamp: Utc::now(),
value: Decimal::from(100),
label: None,
};
sparkline.add_point(data);
assert!(sparkline.has_data());
assert_eq!(sparkline.current_value(), Some(Decimal::from(100)));
}
#[test]
fn test_value_change() {
let mut sparkline = Sparkline::new("Test", 10);
let points = create_test_data(vec![100.0, 110.0, 105.0]);
sparkline.add_points(points);
let (change, percentage) = sparkline.value_change().unwrap();
assert_eq!(change, Decimal::from(5)); // 105 - 100
assert_eq!(percentage, Decimal::from(5)); // 5%
}
#[test]
fn test_auto_scaling() {
let mut sparkline = Sparkline::new("Test", 10).with_auto_scale(true);
let points = create_test_data(vec![10.0, 20.0, 30.0, 15.0]);
sparkline.add_points(points);
let (min_val, max_val) = sparkline.range.unwrap();
assert!(min_val < Decimal::from(10));
assert!(max_val > Decimal::from(30));
}
#[test]
fn test_normalize_data() {
let mut sparkline = Sparkline::new("Test", 10);
sparkline.range = Some((Decimal::from(0), Decimal::from(100)));
let points = create_test_data(vec![0.0, 50.0, 100.0]);
sparkline.add_points(points);
let normalized = sparkline.normalize_data();
assert_eq!(normalized, vec![0, 50, 100]);
}
#[test]
fn test_trend_color() {
let mut sparkline = Sparkline::new("Test", 10);
let colors = FinancialColors::default();
// Positive trend
let points = create_test_data(vec![100.0, 110.0]);
sparkline.add_points(points);
assert_eq!(sparkline.trend_color(), colors.profit);
// Negative trend
sparkline.clear();
let points = create_test_data(vec![100.0, 90.0]);
sparkline.add_points(points);
assert_eq!(sparkline.trend_color(), colors.loss);
}
#[test]
fn test_update_data() {
let mut sparkline = Sparkline::new("Test", 10);
let points = create_test_data(vec![1.0, 2.0, 3.0]);
sparkline.update_data(points);
assert_eq!(sparkline.data.len(), 3);
assert_eq!(sparkline.current_value(), Some(Decimal::from(3)));
}
}

File diff suppressed because it is too large Load Diff