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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-03 01:26:45 +01:00
parent e1bab1a587
commit d4d070b70b
6 changed files with 383 additions and 0 deletions

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -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<TrainingSession>,
pub gpu: GpuInfo,
/// Ring buffer of recent loss values (max 200).
pub loss_history: VecDeque<f64>,
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<PositionRow>,
/// Recent fills ring buffer (max 50).
pub fills: VecDeque<FillRow>,
pub day_pnl: f64,
pub realized_pnl: f64,
pub unrealized_pnl: f64,
pub scroll_offset: usize,
pub connected: bool,
}
const MAX_FILLS: usize = 50;
impl TradingTabState {
/// Append a fill, evicting the oldest if the buffer is full.
pub fn push_fill(&mut self, fill: FillRow) {
if self.fills.len() >= MAX_FILLS {
self.fills.pop_front();
}
self.fills.push_back(fill);
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// Risk types
// ---------------------------------------------------------------------------
/// A single circuit breaker status row.
#[derive(Debug, Clone)]
pub struct CircuitBreakerRow {
pub name: String,
pub status: String,
pub current: String,
pub limit: String,
}
/// State for the **Risk** tab.
#[derive(Debug, Default)]
pub struct RiskTabState {
pub portfolio_var: f64,
pub max_drawdown: f64,
pub current_drawdown: f64,
pub sharpe_ratio: f64,
pub circuit_breakers: Vec<CircuitBreakerRow>,
pub scroll_offset: usize,
pub connected: bool,
}
impl RiskTabState {
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// System types
// ---------------------------------------------------------------------------
/// Health status of a single micro-service.
#[derive(Debug, Clone)]
pub struct ServiceRow {
pub name: String,
pub status: String,
pub message: String,
pub latency_ms: f64,
}
/// State for the **System** tab.
#[derive(Debug, Default)]
pub struct SystemTabState {
pub services: Vec<ServiceRow>,
pub gpu: GpuInfo,
pub cpu_percent: f64,
pub ram_used_gb: f64,
pub ram_total_gb: f64,
pub scroll_offset: usize,
pub connected: bool,
}
impl SystemTabState {
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
// ---------------------------------------------------------------------------
// Top-level dashboard state
// ---------------------------------------------------------------------------
/// Root state object shared by the event loop and renderer.
#[derive(Debug, Default)]
pub struct DashboardState {
pub active_tab: usize,
pub training: TrainingTabState,
pub trading: TradingTabState,
pub risk: RiskTabState,
pub system: SystemTabState,
/// Set to `true` whenever any field changes so the renderer knows to
/// repaint. The render pass resets it to `false`.
pub dirty: bool,
}
impl DashboardState {
/// Returns the [`Tab`] variant for the currently active index.
pub fn current_tab(&self) -> Tab {
*Tab::ALL
.get(self.active_tab)
.unwrap_or(&Tab::Training)
}
/// Switch to a tab by index (clamped to valid range).
pub fn select_tab(&mut self, index: usize) {
if index < Tab::ALL.len() {
self.active_tab = index;
self.dirty = true;
}
}
/// Delegate scroll-up to the active tab.
pub fn scroll_up(&mut self) {
match self.current_tab() {
Tab::Training => self.training.scroll_up(),
Tab::Trading => self.trading.scroll_up(),
Tab::Risk => self.risk.scroll_up(),
Tab::System => self.system.scroll_up(),
}
self.dirty = true;
}
/// Delegate scroll-down to the active tab.
pub fn scroll_down(&mut self) {
match self.current_tab() {
Tab::Training => self.training.scroll_down(),
Tab::Trading => self.trading.scroll_down(),
Tab::Risk => self.risk.scroll_down(),
Tab::System => self.system.scroll_down(),
}
self.dirty = true;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_tab_selection() {
let mut state = DashboardState::default();
assert_eq!(state.current_tab(), Tab::Training);
state.select_tab(2);
assert_eq!(state.current_tab(), Tab::Risk);
assert_eq!(state.active_tab, 2);
assert!(state.dirty);
}
#[test]
fn test_tab_selection_out_of_bounds() {
let mut state = DashboardState::default();
state.select_tab(99);
// Should remain at the default (0 = Training).
assert_eq!(state.active_tab, 0);
assert_eq!(state.current_tab(), Tab::Training);
}
#[test]
fn test_loss_history_ring_buffer() {
let mut ts = TrainingTabState::default();
for i in 0..250 {
ts.push_loss(i as f64);
}
assert_eq!(ts.loss_history.len(), 200);
// Oldest surviving entry should be 50.0 (0..49 were evicted).
assert_eq!(*ts.loss_history.front().unwrap(), 50.0);
}
#[test]
fn test_fill_ring_buffer() {
let mut ts = TradingTabState::default();
for i in 0..60 {
ts.push_fill(FillRow {
time: format!("t{i}"),
side: "Buy".into(),
symbol: "ES".into(),
quantity: 1.0,
price: 5000.0,
status: "Filled".into(),
});
}
assert_eq!(ts.fills.len(), 50);
}
#[test]
fn test_scroll() {
let mut state = DashboardState::default();
// Scroll down twice.
state.scroll_down();
state.scroll_down();
assert_eq!(state.training.scroll_offset, 2);
// Scroll up once.
state.scroll_up();
assert_eq!(state.training.scroll_offset, 1);
// Scroll up past zero -- must not underflow.
state.scroll_up();
state.scroll_up();
assert_eq!(state.training.scroll_offset, 0);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement