# 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:

```rust
    // 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:

```rust
// ---------------------------------------------------------------------------
// 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:

```rust
    #[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`:

```rust
//! 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:
```rust
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:
```rust
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:

```rust
            // 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:

```rust
            // 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:

```protobuf
  // 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:

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

After `GpuSnapshot` message:

```protobuf
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:

```rust
            // 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`:

```rust
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:

```rust
// 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:

```rust
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**

```rust
    #[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:

```rust
    // 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):

```rust
    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):

```rust
        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`:

```rust
        // 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:
```rust
        Cell::from("Sharpe"),
        Cell::from("Win%"),
```

In the row builder (~line 173), after the gradient_norm cell, add:
```rust
                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:
```rust
        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):

```rust
    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:

```rust
        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:

```rust
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);`:

```rust
    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
```
