From d4d070b70b5277fff4ef5d412a00ff9ec05d6630 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 3 Mar 2026 01:26:45 +0100 Subject: [PATCH] feat(fxt): add dashboard state types with 5 tests DashboardState with 4 tab substates (Training/Trading/Risk/System), ring buffers for loss history and fills, scroll support. Co-Authored-By: Claude Opus 4.6 --- bin/fxt/src/commands/mod.rs | 1 + bin/fxt/src/commands/watch/event_loop.rs | 5 + bin/fxt/src/commands/watch/mod.rs | 10 + bin/fxt/src/commands/watch/render.rs | 1 + bin/fxt/src/commands/watch/state.rs | 365 +++++++++++++++++++++++ bin/fxt/src/commands/watch/streams.rs | 1 + 6 files changed, 383 insertions(+) create mode 100644 bin/fxt/src/commands/watch/event_loop.rs create mode 100644 bin/fxt/src/commands/watch/mod.rs create mode 100644 bin/fxt/src/commands/watch/render.rs create mode 100644 bin/fxt/src/commands/watch/state.rs create mode 100644 bin/fxt/src/commands/watch/streams.rs diff --git a/bin/fxt/src/commands/mod.rs b/bin/fxt/src/commands/mod.rs index c9fa8c839..5926c6f20 100644 --- a/bin/fxt/src/commands/mod.rs +++ b/bin/fxt/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod trade; pub mod trade_ml; pub mod train; pub mod tune; +pub mod watch; // TODO: Enable tune_stream when API Gateway implements streaming support // pub mod tune_stream; diff --git a/bin/fxt/src/commands/watch/event_loop.rs b/bin/fxt/src/commands/watch/event_loop.rs new file mode 100644 index 000000000..306a241bb --- /dev/null +++ b/bin/fxt/src/commands/watch/event_loop.rs @@ -0,0 +1,5 @@ +use anyhow::Result; + +pub(super) async fn run_dashboard(_api_gateway_url: &str, _jwt_token: &str) -> Result<()> { + anyhow::bail!("Dashboard event loop not yet implemented") +} diff --git a/bin/fxt/src/commands/watch/mod.rs b/bin/fxt/src/commands/watch/mod.rs new file mode 100644 index 000000000..9241db720 --- /dev/null +++ b/bin/fxt/src/commands/watch/mod.rs @@ -0,0 +1,10 @@ +pub mod state; +mod streams; +mod render; +mod event_loop; + +use anyhow::Result; + +pub async fn run(api_gateway_url: &str, jwt_token: &str) -> Result<()> { + event_loop::run_dashboard(api_gateway_url, jwt_token).await +} diff --git a/bin/fxt/src/commands/watch/render.rs b/bin/fxt/src/commands/watch/render.rs new file mode 100644 index 000000000..1039d4c50 --- /dev/null +++ b/bin/fxt/src/commands/watch/render.rs @@ -0,0 +1 @@ +// TODO: implement diff --git a/bin/fxt/src/commands/watch/state.rs b/bin/fxt/src/commands/watch/state.rs new file mode 100644 index 000000000..cefbfa637 --- /dev/null +++ b/bin/fxt/src/commands/watch/state.rs @@ -0,0 +1,365 @@ +//! Dashboard state types for the `fxt watch` streaming TUI. +//! +//! All mutable state lives here so that the render and event-loop modules +//! can borrow it without circular dependencies. + +use std::collections::VecDeque; + +// --------------------------------------------------------------------------- +// Tab enum +// --------------------------------------------------------------------------- + +/// Top-level dashboard tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Training, + Trading, + Risk, + System, +} + +impl Tab { + /// All tabs in display order. + pub const ALL: [Tab; 4] = [Tab::Training, Tab::Trading, Tab::Risk, Tab::System]; + + /// Human-readable title for the tab bar. + pub fn title(self) -> &'static str { + match self { + Tab::Training => "Training", + Tab::Trading => "Trading", + Tab::Risk => "Risk", + Tab::System => "System", + } + } + + /// Zero-based index matching [`Tab::ALL`]. + pub fn index(self) -> usize { + match self { + Tab::Training => 0, + Tab::Trading => 1, + Tab::Risk => 2, + Tab::System => 3, + } + } +} + +// --------------------------------------------------------------------------- +// Training types +// --------------------------------------------------------------------------- + +/// A single in-progress training session. +#[derive(Debug, Clone)] +pub struct TrainingSession { + pub model: String, + pub fold: String, + pub is_hyperopt: bool, + pub epoch: f32, + pub epoch_loss: f32, + pub validation_loss: f32, + pub learning_rate: f32, + pub gpu_percent: f32, + pub batches_per_second: f32, + pub gradient_norm: f32, + pub epoch_duration_seconds: f32, +} + +/// GPU telemetry snapshot. +#[derive(Debug, Default, Clone)] +pub struct GpuInfo { + pub utilization_percent: f32, + pub memory_used_mb: f32, + pub memory_total_mb: f32, + pub temperature_celsius: f32, +} + +/// State for the **Training** tab. +#[derive(Debug, Default)] +pub struct TrainingTabState { + pub sessions: Vec, + pub gpu: GpuInfo, + /// Ring buffer of recent loss values (max 200). + pub loss_history: VecDeque, + pub scroll_offset: usize, + pub connected: bool, +} + +const MAX_LOSS_HISTORY: usize = 200; + +impl TrainingTabState { + /// Append a loss sample, evicting the oldest if the buffer is full. + pub fn push_loss(&mut self, value: f64) { + if self.loss_history.len() >= MAX_LOSS_HISTORY { + self.loss_history.pop_front(); + } + self.loss_history.push_back(value); + } + + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } +} + +// --------------------------------------------------------------------------- +// Trading types +// --------------------------------------------------------------------------- + +/// A single open position. +#[derive(Debug, Clone)] +pub struct PositionRow { + pub symbol: String, + pub side: String, + pub quantity: f64, + pub entry_price: f64, + pub unrealized_pnl: f64, + pub status: String, +} + +/// A single fill / execution. +#[derive(Debug, Clone)] +pub struct FillRow { + pub time: String, + pub side: String, + pub symbol: String, + pub quantity: f64, + pub price: f64, + pub status: String, +} + +/// State for the **Trading** tab. +#[derive(Debug, Default)] +pub struct TradingTabState { + pub positions: Vec, + /// Recent fills ring buffer (max 50). + pub fills: VecDeque, + pub day_pnl: f64, + pub realized_pnl: f64, + pub unrealized_pnl: f64, + pub scroll_offset: usize, + pub connected: bool, +} + +const MAX_FILLS: usize = 50; + +impl TradingTabState { + /// Append a fill, evicting the oldest if the buffer is full. + pub fn push_fill(&mut self, fill: FillRow) { + if self.fills.len() >= MAX_FILLS { + self.fills.pop_front(); + } + self.fills.push_back(fill); + } + + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } +} + +// --------------------------------------------------------------------------- +// Risk types +// --------------------------------------------------------------------------- + +/// A single circuit breaker status row. +#[derive(Debug, Clone)] +pub struct CircuitBreakerRow { + pub name: String, + pub status: String, + pub current: String, + pub limit: String, +} + +/// State for the **Risk** tab. +#[derive(Debug, Default)] +pub struct RiskTabState { + pub portfolio_var: f64, + pub max_drawdown: f64, + pub current_drawdown: f64, + pub sharpe_ratio: f64, + pub circuit_breakers: Vec, + pub scroll_offset: usize, + pub connected: bool, +} + +impl RiskTabState { + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } +} + +// --------------------------------------------------------------------------- +// System types +// --------------------------------------------------------------------------- + +/// Health status of a single micro-service. +#[derive(Debug, Clone)] +pub struct ServiceRow { + pub name: String, + pub status: String, + pub message: String, + pub latency_ms: f64, +} + +/// State for the **System** tab. +#[derive(Debug, Default)] +pub struct SystemTabState { + pub services: Vec, + pub gpu: GpuInfo, + pub cpu_percent: f64, + pub ram_used_gb: f64, + pub ram_total_gb: f64, + pub scroll_offset: usize, + pub connected: bool, +} + +impl SystemTabState { + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } +} + +// --------------------------------------------------------------------------- +// Top-level dashboard state +// --------------------------------------------------------------------------- + +/// Root state object shared by the event loop and renderer. +#[derive(Debug, Default)] +pub struct DashboardState { + pub active_tab: usize, + pub training: TrainingTabState, + pub trading: TradingTabState, + pub risk: RiskTabState, + pub system: SystemTabState, + /// Set to `true` whenever any field changes so the renderer knows to + /// repaint. The render pass resets it to `false`. + pub dirty: bool, +} + +impl DashboardState { + /// Returns the [`Tab`] variant for the currently active index. + pub fn current_tab(&self) -> Tab { + *Tab::ALL + .get(self.active_tab) + .unwrap_or(&Tab::Training) + } + + /// Switch to a tab by index (clamped to valid range). + pub fn select_tab(&mut self, index: usize) { + if index < Tab::ALL.len() { + self.active_tab = index; + self.dirty = true; + } + } + + /// Delegate scroll-up to the active tab. + pub fn scroll_up(&mut self) { + match self.current_tab() { + Tab::Training => self.training.scroll_up(), + Tab::Trading => self.trading.scroll_up(), + Tab::Risk => self.risk.scroll_up(), + Tab::System => self.system.scroll_up(), + } + self.dirty = true; + } + + /// Delegate scroll-down to the active tab. + pub fn scroll_down(&mut self) { + match self.current_tab() { + Tab::Training => self.training.scroll_down(), + Tab::Trading => self.trading.scroll_down(), + Tab::Risk => self.risk.scroll_down(), + Tab::System => self.system.scroll_down(), + } + self.dirty = true; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn test_tab_selection() { + let mut state = DashboardState::default(); + assert_eq!(state.current_tab(), Tab::Training); + + state.select_tab(2); + assert_eq!(state.current_tab(), Tab::Risk); + assert_eq!(state.active_tab, 2); + assert!(state.dirty); + } + + #[test] + fn test_tab_selection_out_of_bounds() { + let mut state = DashboardState::default(); + state.select_tab(99); + // Should remain at the default (0 = Training). + assert_eq!(state.active_tab, 0); + assert_eq!(state.current_tab(), Tab::Training); + } + + #[test] + fn test_loss_history_ring_buffer() { + let mut ts = TrainingTabState::default(); + for i in 0..250 { + ts.push_loss(i as f64); + } + assert_eq!(ts.loss_history.len(), 200); + // Oldest surviving entry should be 50.0 (0..49 were evicted). + assert_eq!(*ts.loss_history.front().unwrap(), 50.0); + } + + #[test] + fn test_fill_ring_buffer() { + let mut ts = TradingTabState::default(); + for i in 0..60 { + ts.push_fill(FillRow { + time: format!("t{i}"), + side: "Buy".into(), + symbol: "ES".into(), + quantity: 1.0, + price: 5000.0, + status: "Filled".into(), + }); + } + assert_eq!(ts.fills.len(), 50); + } + + #[test] + fn test_scroll() { + let mut state = DashboardState::default(); + + // Scroll down twice. + state.scroll_down(); + state.scroll_down(); + assert_eq!(state.training.scroll_offset, 2); + + // Scroll up once. + state.scroll_up(); + assert_eq!(state.training.scroll_offset, 1); + + // Scroll up past zero -- must not underflow. + state.scroll_up(); + state.scroll_up(); + assert_eq!(state.training.scroll_offset, 0); + } +} diff --git a/bin/fxt/src/commands/watch/streams.rs b/bin/fxt/src/commands/watch/streams.rs new file mode 100644 index 000000000..1039d4c50 --- /dev/null +++ b/bin/fxt/src/commands/watch/streams.rs @@ -0,0 +1 @@ +// TODO: implement