Files
foxhunt/docs/plans/2026-03-03-epoch-financial-metrics-implementation.md
jgrusewski 77fe520e08 feat(fxt,ml): add fxt train monitor command and update DQN tests for action collapse fix
Add live training metrics monitor CLI command (streaming & one-shot) using
the monitoring gRPC service. Update DQN tests to match post-fix defaults:
IQN disabled, CQL alpha=0.1, v_min/v_max widened, 26D search space.

- train.rs: `fxt train monitor [--once] [--model X] [--interval N]`
- Rewrite gradient collapse test for BF16 mixed precision awareness
- Update inference test config to match trainer defaults (IQN off, CQL on)
- Update production smoke test for 26D parameter space
- Add dqn_action_collapse_fix_test.rs verifying all 6 root cause fixes
- Add planning docs for monitoring service and epoch financial metrics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:58:40 +01:00

31 KiB

Epoch-Level Financial Metrics Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Surface per-epoch financial metrics (Sharpe, Sortino, win rate, max DD, profit factor, total return, avg return, total trades, action distribution) from ML trainers through the Prometheus→monitoring→fxt pipeline, with epoch history ring buffer.

Architecture: Add 11 Prometheus gauges to common::metrics::training_metrics, push them from DQN/PPO trainers at epoch-end using data already in pnl_history and action_counts. Add a compute_epoch_financials() helper to ml/src/trainers that computes Sharpe/DD/etc. from the PnL deque. Extend monitoring.proto with 11 new fields (36-46) + a GetEpochHistory RPC. Wire through monitoring service, fxt state, and render.

Tech Stack: Rust, Prometheus (via common::metrics), protobuf/tonic, ratatui


Task 1: Add Prometheus Gauges for Financial Metrics

Files:

  • Modify: crates/common/src/metrics/training_metrics.rs

Step 1: Register 11 new gauges in init()

After the action_diversity gauge registration (line ~184), add:

    // Epoch-level financial metrics (model + fold)
    _ = register_gauge_vec(
        "foxhunt_training_epoch_sharpe",
        "Epoch Sharpe ratio from validation backtest",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_sortino",
        "Epoch Sortino ratio from validation backtest",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_win_rate",
        "Epoch win rate 0-1",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_max_drawdown",
        "Epoch max drawdown 0-1",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_profit_factor",
        "Epoch profit factor (gross profit / gross loss)",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_total_return",
        "Epoch total return (fractional)",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_avg_return",
        "Epoch average return per trade",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_total_trades",
        "Epoch total trade count",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_action_buy_pct",
        "BUY action percentage 0-1",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_action_sell_pct",
        "SELL action percentage 0-1",
        mf,
    );
    _ = register_gauge_vec(
        "foxhunt_training_epoch_action_hold_pct",
        "HOLD action percentage 0-1",
        mf,
    );

Step 2: Add two convenience functions

After the set_action_diversity function (line ~449), add:

// ---------------------------------------------------------------------------
// Tier 1: Epoch financial metrics
// ---------------------------------------------------------------------------

/// Push a full set of epoch-level financial metrics from a backtest evaluation.
pub fn set_epoch_financial_metrics(
    model: &str,
    fold: &str,
    sharpe: f64,
    sortino: f64,
    win_rate: f64,
    max_drawdown: f64,
    profit_factor: f64,
    total_return: f64,
    avg_return: f64,
    total_trades: f64,
) {
    set_gauge_vec("foxhunt_training_epoch_sharpe", &[model, fold], sharpe);
    set_gauge_vec("foxhunt_training_epoch_sortino", &[model, fold], sortino);
    set_gauge_vec("foxhunt_training_epoch_win_rate", &[model, fold], win_rate);
    set_gauge_vec("foxhunt_training_epoch_max_drawdown", &[model, fold], max_drawdown);
    set_gauge_vec("foxhunt_training_epoch_profit_factor", &[model, fold], profit_factor);
    set_gauge_vec("foxhunt_training_epoch_total_return", &[model, fold], total_return);
    set_gauge_vec("foxhunt_training_epoch_avg_return", &[model, fold], avg_return);
    set_gauge_vec("foxhunt_training_epoch_total_trades", &[model, fold], total_trades);
}

/// Push epoch action distribution percentages (all 0-1).
pub fn set_epoch_action_distribution(
    model: &str,
    fold: &str,
    buy_pct: f64,
    sell_pct: f64,
    hold_pct: f64,
) {
    set_gauge_vec("foxhunt_training_epoch_action_buy_pct", &[model, fold], buy_pct);
    set_gauge_vec("foxhunt_training_epoch_action_sell_pct", &[model, fold], sell_pct);
    set_gauge_vec("foxhunt_training_epoch_action_hold_pct", &[model, fold], hold_pct);
}

Step 3: Add tests

Add after the existing test_tier1_helpers_no_panic test:

    #[test]
    fn test_epoch_financial_metrics_no_panic() {
        init();
        set_epoch_financial_metrics("dqn", "0", 2.31, 3.12, 0.55, 0.08, 1.84, 0.124, 0.003, 142.0);
        set_epoch_action_distribution("dqn", "0", 0.35, 0.25, 0.40);
    }

Step 4: Update doc comment

Change line 1 doc comment from 41 metrics to 52 metrics.

Step 5: Verify

Run: SQLX_OFFLINE=true cargo test -p common --lib -- training_metrics Expected: all tests pass including the new one.

Step 6: Commit

feat(common): add 11 Prometheus gauges for epoch financial metrics

Task 2: Add compute_epoch_financials Helper in ML Crate

Files:

  • Create: crates/ml/src/trainers/dqn/financials.rs
  • Modify: crates/ml/src/trainers/dqn/mod.rs (add pub(crate) mod financials;)

Step 1: Write the test

In financials.rs:

//! Epoch-level financial metrics computed from the DQN trainer's PnL history
//! and action counts. These are pushed to Prometheus at the end of each epoch.

use std::collections::VecDeque;

/// Financial metrics summary for a single training epoch.
#[derive(Debug, Clone, Default)]
pub(crate) struct EpochFinancials {
    pub sharpe: f64,
    pub sortino: f64,
    pub win_rate: f64,
    pub max_drawdown: f64,
    pub profit_factor: f64,
    pub total_return: f64,
    pub avg_return: f64,
    pub total_trades: usize,
    pub buy_pct: f64,
    pub sell_pct: f64,
    pub hold_pct: f64,
}

/// Compute financial metrics from the trainer's PnL history and action counts.
///
/// - `pnl_history`: per-step PnL values accumulated this epoch
/// - `action_counts`: 45-element array (FactoredAction), or simpler 3-group aggregation
/// - `initial_capital`: starting equity for return calculation (default 100_000)
pub(crate) fn compute_epoch_financials(
    pnl_history: &VecDeque<f64>,
    action_counts: &[usize; 45],
    initial_capital: f64,
) -> EpochFinancials {
    if pnl_history.is_empty() {
        return EpochFinancials::default();
    }

    let returns: Vec<f64> = pnl_history.iter().copied().collect();
    let n = returns.len();

    // Total return
    let total_pnl: f64 = returns.iter().sum();
    let total_return = total_pnl / initial_capital;

    // Win rate
    let winning = returns.iter().filter(|&&r| r > 0.0).count();
    let win_rate = winning as f64 / n as f64;

    // Average return per trade
    let avg_return = total_pnl / n as f64;

    // Sharpe ratio (annualized, 252 trading days)
    let mean = total_pnl / n as f64;
    let variance: f64 = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64;
    let std = variance.sqrt();
    let sharpe = if std > 1e-10 {
        (mean / std) * (252.0_f64).sqrt()
    } else {
        0.0
    };

    // Sortino ratio (only downside deviation)
    let downside_returns: Vec<f64> = returns.iter().filter(|&&r| r < 0.0).copied().collect();
    let sortino = if downside_returns.len() > 1 {
        let down_var: f64 = downside_returns.iter().map(|r| r.powi(2)).sum::<f64>()
            / downside_returns.len() as f64;
        let down_std = down_var.sqrt();
        if down_std > 1e-10 {
            (mean / down_std) * (252.0_f64).sqrt()
        } else {
            0.0
        }
    } else {
        0.0
    };

    // Max drawdown
    let mut equity = initial_capital;
    let mut peak = equity;
    let mut max_dd = 0.0_f64;
    for &pnl in &returns {
        equity += pnl;
        if equity > peak {
            peak = equity;
        }
        let dd = (peak - equity) / peak;
        if dd > max_dd {
            max_dd = dd;
        }
    }

    // Profit factor
    let gross_profit: f64 = returns.iter().filter(|&&r| r > 0.0).sum();
    let gross_loss: f64 = returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum();
    let profit_factor = if gross_loss > 1e-10 {
        gross_profit / gross_loss
    } else if gross_profit > 0.0 {
        f64::INFINITY
    } else {
        0.0
    };

    // Action distribution: group 45 actions into BUY/SELL/HOLD
    // FactoredAction: exposure(5) x order(3) x urgency(3)
    // Exposure 0,1 = reduce/close (SELL-like), 2 = hold, 3,4 = add/aggressive (BUY-like)
    // Simplification: map by exposure dimension (index / 9 gives exposure 0-4)
    let total_actions: usize = action_counts.iter().sum();
    let (buy_pct, sell_pct, hold_pct) = if total_actions > 0 {
        let mut buy = 0usize;
        let mut sell = 0usize;
        let mut hold = 0usize;
        for (i, &count) in action_counts.iter().enumerate() {
            let exposure = i / 9; // 0-4
            match exposure {
                0 | 1 => sell += count,  // reduce/close
                2 => hold += count,       // neutral
                3 | 4 => buy += count,    // add/aggressive
                _ => {}
            }
        }
        let t = total_actions as f64;
        (buy as f64 / t, sell as f64 / t, hold as f64 / t)
    } else {
        (0.0, 0.0, 0.0)
    };

    EpochFinancials {
        sharpe,
        sortino,
        win_rate,
        max_drawdown: max_dd,
        profit_factor,
        total_return,
        avg_return,
        total_trades: n,
        buy_pct,
        sell_pct,
        hold_pct,
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_empty_history() {
        let f = compute_epoch_financials(&VecDeque::new(), &[0; 45], 100_000.0);
        assert_eq!(f.total_trades, 0);
        assert_eq!(f.sharpe, 0.0);
    }

    #[test]
    fn test_all_winning() {
        let pnl: VecDeque<f64> = vec![10.0, 20.0, 30.0, 15.0, 25.0].into();
        let f = compute_epoch_financials(&pnl, &[0; 45], 100_000.0);
        assert_eq!(f.win_rate, 1.0);
        assert_eq!(f.total_trades, 5);
        assert!(f.sharpe > 0.0);
        assert!(f.max_drawdown < 1e-10); // No drawdown with all wins
        assert!(f.profit_factor.is_infinite()); // No losses
    }

    #[test]
    fn test_mixed_pnl() {
        let pnl: VecDeque<f64> = vec![100.0, -50.0, 75.0, -25.0, 50.0].into();
        let f = compute_epoch_financials(&pnl, &[0; 45], 100_000.0);
        assert_eq!(f.total_trades, 5);
        assert!((f.win_rate - 0.6).abs() < 1e-10);
        assert!(f.total_return > 0.0);
        assert!(f.profit_factor > 1.0);
        assert!(f.max_drawdown > 0.0);
        assert!(f.sortino > 0.0);
    }

    #[test]
    fn test_action_distribution() {
        let mut actions = [0usize; 45];
        // Exposure 3 (add), order 0, urgency 0 → index 27
        actions[27] = 100; // BUY
        // Exposure 0 (reduce), order 0, urgency 0 → index 0
        actions[0] = 50; // SELL
        // Exposure 2 (neutral), order 0, urgency 0 → index 18
        actions[18] = 50; // HOLD
        let pnl: VecDeque<f64> = vec![1.0].into();
        let f = compute_epoch_financials(&pnl, &actions, 100_000.0);
        assert!((f.buy_pct - 0.5).abs() < 1e-10);
        assert!((f.sell_pct - 0.25).abs() < 1e-10);
        assert!((f.hold_pct - 0.25).abs() < 1e-10);
    }
}

Step 2: Add module declaration

In crates/ml/src/trainers/dqn/mod.rs, add:

pub(crate) mod financials;

Step 3: Verify

Run: SQLX_OFFLINE=true cargo test -p ml --lib -- financials Expected: 4 tests pass.

Step 4: Commit

feat(ml): add compute_epoch_financials helper for DQN/PPO

Task 3: Push Financial Metrics from DQN Trainer

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs (~line 2789, after VaR/CVaR block)

Step 1: Add import at the top of the file

After line 12 (use common::metrics::training_metrics;), it's already imported. No new import needed for training_metrics.

Add near other use statements:

use super::financials::compute_epoch_financials;

Step 2: Push financial metrics after VaR/CVaR block

After the VaR/CVaR log block (~line 2789), before the // Track metrics for early stopping comment, add:

            // Epoch financial metrics for monitoring service
            {
                let financials = compute_epoch_financials(
                    &self.pnl_history,
                    &monitor.action_counts,
                    100_000.0,
                );
                training_metrics::set_epoch_financial_metrics(
                    "dqn", "current",
                    financials.sharpe,
                    financials.sortino,
                    financials.win_rate,
                    financials.max_drawdown,
                    financials.profit_factor,
                    financials.total_return,
                    financials.avg_return,
                    financials.total_trades as f64,
                );
                training_metrics::set_epoch_action_distribution(
                    "dqn", "current",
                    financials.buy_pct,
                    financials.sell_pct,
                    financials.hold_pct,
                );
                info!(
                    "Epoch {}/{}: Sharpe={:.2} WinRate={:.1}% MaxDD={:.1}% PF={:.2} Return={:+.2}% Trades={}",
                    epoch + 1, self.hyperparams.epochs,
                    financials.sharpe, financials.win_rate * 100.0,
                    financials.max_drawdown * 100.0, financials.profit_factor,
                    financials.total_return * 100.0, financials.total_trades,
                );
            }

Step 3: Verify

Run: SQLX_OFFLINE=true cargo check -p ml Expected: compiles cleanly.

Step 4: Commit

feat(ml): push epoch financial metrics from DQN trainer

Task 4: Push Financial Metrics from PPO Trainer

Files:

  • Modify: crates/ml/src/trainers/ppo.rs (~line 679, after verbose metrics)

Step 1: Add reward-to-PnL and action tracking

PPO doesn't have pnl_history or action_counts like DQN. PPO tracks mean_reward and std_reward per epoch. We compute a simplified Sharpe from rewards directly.

After the existing Prometheus pushes (~line 679), add:

            // Epoch financial metrics (simplified for PPO — derived from reward stats)
            // PPO doesn't run a backtest per epoch; use reward mean/std as proxy
            {
                let epoch_sharpe = if std_reward > 1e-10 {
                    (mean_reward / std_reward) as f64 * (252.0_f64).sqrt()
                } else {
                    0.0
                };
                training_metrics::set_epoch_financial_metrics(
                    "ppo", "current",
                    epoch_sharpe,
                    0.0,  // sortino: not available without per-step returns
                    0.0,  // win_rate: not tracked per epoch in PPO
                    0.0,  // max_drawdown: not tracked per epoch in PPO
                    0.0,  // profit_factor: not tracked
                    mean_reward as f64, // total_return proxy
                    mean_reward as f64, // avg_return proxy
                    0.0,  // total_trades: not applicable for PPO
                );
            }

Step 2: Verify

Run: SQLX_OFFLINE=true cargo check -p ml Expected: compiles cleanly.

Step 3: Commit

feat(ml): push epoch financial metrics from PPO trainer

Task 5: Add Financial Fields to monitoring.proto

Files:

  • Modify: bin/fxt/proto/monitoring.proto
  • Modify: services/monitoring_service/proto/monitoring.proto

Step 1: Add 11 fields to TrainingSession in both proto files

After float hyperopt_elapsed_seconds = 35;, add:

  // Epoch-level financial metrics
  float epoch_sharpe = 36;
  float epoch_sortino = 37;
  float epoch_win_rate = 38;
  float epoch_max_drawdown = 39;
  float epoch_profit_factor = 40;
  float epoch_total_return = 41;
  float epoch_avg_return = 42;
  uint32 epoch_total_trades = 43;
  // Action distribution
  float action_buy_pct = 44;
  float action_sell_pct = 45;
  float action_hold_pct = 46;

Step 2: Add GetEpochHistory RPC + messages to both proto files

After the existing StreamTrainingMetrics RPC:

  // Epoch history for a specific session (ring buffer, max 50 epochs)
  rpc GetEpochHistory(GetEpochHistoryRequest)
      returns (GetEpochHistoryResponse);

After GpuSnapshot message:

message GetEpochHistoryRequest {
  string model = 1;
  string fold = 2;
  uint32 max_epochs = 3;  // 0 = all (up to 50)
}

message EpochFinancialSnapshot {
  uint32 epoch = 1;
  float sharpe = 2;
  float sortino = 3;
  float win_rate = 4;
  float max_drawdown = 5;
  float profit_factor = 6;
  float total_return = 7;
  float avg_return = 8;
  uint32 total_trades = 9;
  float loss = 10;
  float val_loss = 11;
  float learning_rate = 12;
  float action_buy_pct = 13;
  float action_sell_pct = 14;
  float action_hold_pct = 15;
}

message GetEpochHistoryResponse {
  string model = 1;
  string fold = 2;
  repeated EpochFinancialSnapshot epochs = 3;
}

Step 3: Verify both proto compilations

Run: SQLX_OFFLINE=true cargo check -p fxt && SQLX_OFFLINE=true cargo check -p monitoring_service Expected: compiles (new proto fields just need mapping).

Step 4: Commit

feat(proto): add epoch financial metrics + GetEpochHistory to monitoring.proto

Task 6: Wire Monitoring Service Mapper + Epoch History

Files:

  • Modify: services/monitoring_service/src/service.rs

Step 1: Add 11 match arms to group_into_sessions

In the match s.name.as_str() block (~line 181), before the _ => {} catch-all:

            // Epoch financial metrics
            "foxhunt_training_epoch_sharpe" => session.epoch_sharpe = s.value as f32,
            "foxhunt_training_epoch_sortino" => session.epoch_sortino = s.value as f32,
            "foxhunt_training_epoch_win_rate" => session.epoch_win_rate = s.value as f32,
            "foxhunt_training_epoch_max_drawdown" => session.epoch_max_drawdown = s.value as f32,
            "foxhunt_training_epoch_profit_factor" => session.epoch_profit_factor = s.value as f32,
            "foxhunt_training_epoch_total_return" => session.epoch_total_return = s.value as f32,
            "foxhunt_training_epoch_avg_return" => session.epoch_avg_return = s.value as f32,
            "foxhunt_training_epoch_total_trades" => session.epoch_total_trades = s.value as u32,
            "foxhunt_training_epoch_action_buy_pct" => session.action_buy_pct = s.value as f32,
            "foxhunt_training_epoch_action_sell_pct" => session.action_sell_pct = s.value as f32,
            "foxhunt_training_epoch_action_hold_pct" => session.action_hold_pct = s.value as f32,

Step 2: Add epoch history store + RPC impl

Add to MonitoringServiceImpl:

use std::collections::VecDeque;
use tokio::sync::RwLock;

use crate::monitoring::{
    EpochFinancialSnapshot, GetEpochHistoryRequest, GetEpochHistoryResponse,
};

const MAX_EPOCH_HISTORY: usize = 50;

pub struct MonitoringServiceImpl {
    prom: Arc<PrometheusClient>,
    default_interval: u32,
    epoch_histories: Arc<RwLock<HashMap<String, VecDeque<EpochFinancialSnapshot>>>>,
    last_epochs: Arc<RwLock<HashMap<String, f32>>>,
}

Update new() to initialize the new fields.

In build_response, after building sessions, detect epoch changes and snapshot:

// After building sessions, check for epoch changes and record history
for session in &sessions {
    let key = format!("{}/{}", session.model, session.fold);
    let mut last = last_epochs.write().await;
    let prev_epoch = last.get(&key).copied().unwrap_or(0.0);
    if session.current_epoch > prev_epoch && session.epoch_sharpe != 0.0 {
        last.insert(key.clone(), session.current_epoch);
        let snapshot = EpochFinancialSnapshot {
            epoch: session.current_epoch as u32,
            sharpe: session.epoch_sharpe,
            sortino: session.epoch_sortino,
            win_rate: session.epoch_win_rate,
            max_drawdown: session.epoch_max_drawdown,
            profit_factor: session.epoch_profit_factor,
            total_return: session.epoch_total_return,
            avg_return: session.epoch_avg_return,
            total_trades: session.epoch_total_trades,
            loss: session.epoch_loss,
            val_loss: session.validation_loss,
            learning_rate: session.learning_rate,
            action_buy_pct: session.action_buy_pct,
            action_sell_pct: session.action_sell_pct,
            action_hold_pct: session.action_hold_pct,
        };
        let mut histories = epoch_histories.write().await;
        let history = histories.entry(key).or_insert_with(|| VecDeque::with_capacity(MAX_EPOCH_HISTORY));
        if history.len() >= MAX_EPOCH_HISTORY {
            history.pop_front();
        }
        history.push_back(snapshot);
    }
}

Implement GetEpochHistory RPC:

async fn get_epoch_history(
    &self,
    request: Request<GetEpochHistoryRequest>,
) -> Result<Response<GetEpochHistoryResponse>, Status> {
    let req = request.into_inner();
    let key = format!("{}/{}", req.model, req.fold);
    let histories = self.epoch_histories.read().await;
    let epochs = match histories.get(&key) {
        Some(deque) => {
            let max = if req.max_epochs == 0 { MAX_EPOCH_HISTORY } else { req.max_epochs as usize };
            deque.iter().rev().take(max).rev().cloned().collect()
        }
        None => vec![],
    };
    Ok(Response::new(GetEpochHistoryResponse {
        model: req.model,
        fold: req.fold,
        epochs,
    }))
}

Step 3: Add test for new metric mapping

    #[test]
    fn test_group_financial_metrics() {
        let samples = vec![
            MetricSample {
                name: "foxhunt_training_epoch_sharpe".to_owned(),
                model: "dqn".to_owned(),
                fold: "0".to_owned(),
                value: 2.31,
            },
            MetricSample {
                name: "foxhunt_training_epoch_win_rate".to_owned(),
                model: "dqn".to_owned(),
                fold: "0".to_owned(),
                value: 0.552,
            },
            MetricSample {
                name: "foxhunt_training_epoch_max_drawdown".to_owned(),
                model: "dqn".to_owned(),
                fold: "0".to_owned(),
                value: 0.081,
            },
            MetricSample {
                name: "foxhunt_training_epoch_action_buy_pct".to_owned(),
                model: "dqn".to_owned(),
                fold: "0".to_owned(),
                value: 0.35,
            },
        ];
        let sessions = group_into_sessions(&samples, "");
        assert_eq!(sessions.len(), 1);
        let s = &sessions[0];
        assert!((s.epoch_sharpe - 2.31).abs() < 0.01);
        assert!((s.epoch_win_rate - 0.552).abs() < 0.001);
        assert!((s.epoch_max_drawdown - 0.081).abs() < 0.001);
        assert!((s.action_buy_pct - 0.35).abs() < 0.01);
    }

Step 4: Verify

Run: SQLX_OFFLINE=true cargo test -p monitoring_service --lib Expected: all tests pass.

Step 5: Commit

feat(monitoring): wire epoch financial metrics + epoch history store

Task 7: Add Financial Fields to fxt State + Stream Mapping

Files:

  • Modify: bin/fxt/src/commands/watch/state.rs
  • Modify: bin/fxt/src/commands/watch/streams.rs

Step 1: Add 11 fields to TrainingSession in state.rs

After the hyperopt_elapsed_seconds field (~line 102), before the gpu_percent field:

    // Epoch financial metrics
    pub epoch_sharpe: f32,
    pub epoch_sortino: f32,
    pub epoch_win_rate: f32,
    pub epoch_max_drawdown: f32,
    pub epoch_profit_factor: f32,
    pub epoch_total_return: f32,
    pub epoch_avg_return: f32,
    pub epoch_total_trades: u32,
    pub action_buy_pct: f32,
    pub action_sell_pct: f32,
    pub action_hold_pct: f32,

Step 2: Add sparkline vectors to SessionHistory

After the hyperopt_best_objective field (~line 173):

    pub sharpe: Vec<f64>,
    pub win_rate: Vec<f64>,
    pub max_drawdown: Vec<f64>,
    pub total_return: Vec<f64>,

Step 3: Push in SessionHistory::push()

After the existing self.hyperopt_best_objective.push(...) (~line 191):

        self.sharpe.push(f64::from(s.epoch_sharpe));
        self.win_rate.push(f64::from(s.epoch_win_rate));
        self.max_drawdown.push(f64::from(s.epoch_max_drawdown));
        self.total_return.push(f64::from(s.epoch_total_return));

Step 4: Add to convert_training_session() in streams.rs

After the hyperopt_elapsed_seconds mapping (~line 164), before gpu_percent:

        // Epoch financial metrics
        epoch_sharpe: s.epoch_sharpe,
        epoch_sortino: s.epoch_sortino,
        epoch_win_rate: s.epoch_win_rate,
        epoch_max_drawdown: s.epoch_max_drawdown,
        epoch_profit_factor: s.epoch_profit_factor,
        epoch_total_return: s.epoch_total_return,
        epoch_avg_return: s.epoch_avg_return,
        epoch_total_trades: s.epoch_total_trades,
        action_buy_pct: s.action_buy_pct,
        action_sell_pct: s.action_sell_pct,
        action_hold_pct: s.action_hold_pct,

Step 5: Update test_convert_training_session test

Add the new fields to the proto struct in the test (all default to 0.0, just ensure it compiles).

Step 6: Verify

Run: SQLX_OFFLINE=true cargo test -p fxt --lib Expected: all tests pass.

Step 7: Commit

feat(fxt): wire epoch financial metrics through state + stream mapping

Task 8: Render Financial Metrics in fxt Watch TUI

Files:

  • Modify: bin/fxt/src/commands/watch/render.rs

Step 1: Add Sharpe + Win Rate columns to list view

In render_training_list, add two columns to the header Row (~line 141):

After Cell::from("Grad"), add:

        Cell::from("Sharpe"),
        Cell::from("Win%"),

In the row builder (~line 173), after the gradient_norm cell, add:

                Cell::from(if s.epoch_sharpe != 0.0 { format!("{:.2}", s.epoch_sharpe) } else { "-".to_owned() }),
                Cell::from(if s.epoch_win_rate > 0.0 { format!("{:.0}%", s.epoch_win_rate * 100.0) } else { "-".to_owned() }),

In the column widths array, add two more Constraint::Length(8) entries.

Step 2: Update detail overview to show financial summary

In render_detail_overview (~line 360), add a line to the stats Paragraph:

After the NaN/Grad/Feature line, add:

        Line::from(format!(
            "  Sharpe: {:.2}    Sortino: {:.2}    Win Rate: {:.1}%    Max DD: {:.1}%",
            session.epoch_sharpe, session.epoch_sortino,
            session.epoch_win_rate * 100.0, session.epoch_max_drawdown * 100.0,
        )),
        Line::from(format!(
            "  PF: {:.2}    Return: {:+.2}%    Avg: {:+.4}    Trades: {}",
            session.epoch_profit_factor, session.epoch_total_return * 100.0,
            session.epoch_avg_return, session.epoch_total_trades,
        )),

Increase the key stats area from Constraint::Length(8) to Constraint::Length(12).

Step 3: Update detail Metrics sub-tab to include financial sparklines

In render_detail_metrics (~line 448), after the existing accuracy/precision/recall/f1 section, add financial sparklines:

Replace the sparkline layout with 8 rows (rebalance constraints):

    let spark_area = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage(12),
            Constraint::Percentage(12),
            Constraint::Percentage(12),
            Constraint::Percentage(12),
            Constraint::Percentage(13),
            Constraint::Percentage(13),
            Constraint::Percentage(13),
            Constraint::Percentage(13),
        ])
        .split(chunks[1]);

    render_sparkline_row(frame, spark_area[0], "Accuracy", &history.accuracy, 1.0, Color::Green);
    render_sparkline_row(frame, spark_area[1], "F1", &history.f1, 1.0, Color::Yellow);
    render_sparkline_row(frame, spark_area[2], "Sharpe", &history.sharpe, 20.0, Color::Cyan);
    render_sparkline_row(frame, spark_area[3], "Win Rate", &history.win_rate, 1.0, Color::Green);
    render_sparkline_row(frame, spark_area[4], "Max DD", &history.max_drawdown, 1.0, Color::Red);
    render_sparkline_row(frame, spark_area[5], "Total Return", &history.total_return, 1.0, Color::Magenta);
    render_sparkline_row(frame, spark_area[6], "Precision", &history.precision, 1.0, Color::Cyan);
    render_sparkline_row(frame, spark_area[7], "Recall", &history.recall, 1.0, Color::Magenta);

Step 4: Add action distribution to Metrics current values

In the current Paragraph, add:

        Line::from(format!(
            "  Action: BUY {:.0}%  SELL {:.0}%  HOLD {:.0}%",
            session.action_buy_pct * 100.0,
            session.action_sell_pct * 100.0,
            session.action_hold_pct * 100.0,
        )),

Step 5: Verify

Run: SQLX_OFFLINE=true cargo check -p fxt Expected: compiles.

Step 6: Commit

feat(fxt): render epoch financial metrics in watch TUI list + detail views

Task 9: Add Financial Section to fxt train monitor

Files:

  • Modify: bin/fxt/src/commands/train/monitor.rs

Step 1: Add print_financial_metrics() function

After print_health_summary (~line 234), add:

fn print_financial_metrics(sessions: &[TrainingSession]) {
    let financial: Vec<_> = sessions
        .iter()
        .filter(|s| s.epoch_sharpe != 0.0 || s.epoch_win_rate > 0.0)
        .collect();
    if financial.is_empty() {
        return;
    }
    println!();
    println!("{}", "Epoch Financial Metrics:".bright_cyan());
    for s in &financial {
        let sharpe_colored = if s.epoch_sharpe >= 2.0 {
            format!("{:.2}", s.epoch_sharpe).green()
        } else if s.epoch_sharpe >= 1.0 {
            format!("{:.2}", s.epoch_sharpe).yellow()
        } else {
            format!("{:.2}", s.epoch_sharpe).red()
        };
        println!(
            "  {}: Sharpe={} WinRate={:.1}% MaxDD={:.1}% PF={:.2} Return={:+.2}% Trades={}",
            s.model.bright_white(),
            sharpe_colored,
            s.epoch_win_rate * 100.0,
            s.epoch_max_drawdown * 100.0,
            s.epoch_profit_factor,
            s.epoch_total_return * 100.0,
            s.epoch_total_trades,
        );
        if s.action_buy_pct > 0.0 || s.action_sell_pct > 0.0 {
            println!(
                "    Actions: BUY {:.0}% | SELL {:.0}% | HOLD {:.0}%",
                s.action_buy_pct * 100.0,
                s.action_sell_pct * 100.0,
                s.action_hold_pct * 100.0,
            );
        }
    }
}

Step 2: Call it from render_snapshot()

In render_snapshot (~line 103), after print_health_summary(&resp.sessions);:

    print_financial_metrics(&resp.sessions);

Step 3: Verify

Run: SQLX_OFFLINE=true cargo check -p fxt Expected: compiles.

Step 4: Commit

feat(fxt): add epoch financial metrics to train monitor output

Task 10: Final Verification + Update Tests

Step 1: Full workspace build

Run: SQLX_OFFLINE=true cargo check --workspace Expected: 0 errors.

Step 2: Run all relevant tests

Run: SQLX_OFFLINE=true cargo test -p common --lib -- training_metrics && SQLX_OFFLINE=true cargo test -p ml --lib -- financials && SQLX_OFFLINE=true cargo test -p monitoring_service --lib && SQLX_OFFLINE=true cargo test -p fxt --lib Expected: all pass.

Step 3: Clippy clean

Run: SQLX_OFFLINE=true cargo clippy --workspace -- -D warnings Expected: 0 warnings.

Step 4: Commit

test: verify epoch financial metrics across workspace