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:
174
Cargo.lock
generated
174
Cargo.lock
generated
@@ -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
106
tests/fixtures/mod.rs
vendored
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
137
tli/src/main.rs
137
tli/src/main.rs
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user