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>
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(addpub(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