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:
@@ -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;
|
||||
|
||||
|
||||
5
bin/fxt/src/commands/watch/event_loop.rs
Normal file
5
bin/fxt/src/commands/watch/event_loop.rs
Normal 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")
|
||||
}
|
||||
10
bin/fxt/src/commands/watch/mod.rs
Normal file
10
bin/fxt/src/commands/watch/mod.rs
Normal 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
|
||||
}
|
||||
1
bin/fxt/src/commands/watch/render.rs
Normal file
1
bin/fxt/src/commands/watch/render.rs
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
365
bin/fxt/src/commands/watch/state.rs
Normal file
365
bin/fxt/src/commands/watch/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1
bin/fxt/src/commands/watch/streams.rs
Normal file
1
bin/fxt/src/commands/watch/streams.rs
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
Reference in New Issue
Block a user