# Enrich backtest insights for strategy iteration

## Context

The scout (LLM strategy author) runs backtests, evaluates results, and decides what to change.
After the flat `result_summary` fields landed (total_positions, win_rate, net_pnl, sharpe_ratio,
etc.), the scout can read basic stats. But it still lacks key signals for deciding **what to
change next**:

- Is the strategy losing money on big losers or small winners? → avg_win / avg_loss / max_loss
- Is the drawdown tolerable? → max_drawdown (in barter tear sheet but not flat)
- Is the strategy over-trading or under-trading? → avg_hold_duration
- How does this run compare to previous attempts? → no comparison endpoint exists
- Are returns consistent or streaky? → max_consecutive_wins/losses

Two changes deliver the most insight with the least effort:

1. **Enrich flat fields** — extend the existing `aggregate_stats` SQL + extract more barter
   tear sheet metrics → one SQL change, a few lines in runner.rs
2. **Compare endpoint** — `GET /api/v1/paper-runs/compare?ids=...` returns flat metrics for
   multiple runs in one call → one new handler, one DAL function, one route

## Part A: Enrich result_summary flat fields

### A1. Extend `PositionAggregateStats` SQL query

**File:** `crates/swym-dal/src/repo/paper_run_position.rs`

Add fields to the existing struct and SQL query (same single-pass over `paper_run_positions`):

```rust
#[derive(Default)]
pub struct PositionAggregateStats {
    // existing
    pub winning: i64,
    pub losing: i64,
    pub total_pnl_net: Decimal,
    pub total_fees: Decimal,
    // new
    pub avg_win: Option<Decimal>,
    pub avg_loss: Option<Decimal>,
    pub max_win: Option<Decimal>,
    pub max_loss: Option<Decimal>,
    pub avg_hold_duration_secs: Option<f64>,
}
```

The `Default` derive works because `Option<T>` defaults to `None`, `i64` to `0`, `Decimal` to `0`.

Extended SQL:

```sql
SELECT
  COUNT(*) FILTER (WHERE pnl > 0),
  COUNT(*) FILTER (WHERE pnl <= 0),
  COALESCE(SUM(pnl), 0),
  COALESCE(SUM(fees_enter + fees_exit), 0),
  AVG(pnl) FILTER (WHERE pnl > 0),
  AVG(pnl) FILTER (WHERE pnl <= 0),
  MAX(pnl),
  MIN(pnl),
  AVG(EXTRACT(EPOCH FROM (time_exit - time_enter)))
FROM paper_run_positions WHERE run_id = $1
```

All aggregates run in one pass. No CTEs, no subqueries. `max_win` = `MAX(pnl)`, `max_loss`
= `MIN(pnl)` (most negative). `avg_hold_duration_secs` as `f64` since it's informational.

### A2. Extract barter tear sheet metrics in runner.rs

**File:** `services/paper-executor/src/runner.rs`

After the existing `sharpe_ratio` extraction, add:

```rust
let sortino_ratio = tear_sheet.map(|t| t.sortino_ratio.value);
let calmar_ratio = tear_sheet.map(|t| t.calmar_ratio.value);
let max_drawdown = tear_sheet.and_then(|t| t.pnl_drawdown_max.as_ref()).map(|d| d.0.value);
let pnl_return = tear_sheet.map(|t| t.pnl_return.value);
```

Include in the `serde_json::json!({})` block (both candle and tick paths):

```rust
"sortino_ratio": sortino_ratio,
"calmar_ratio": calmar_ratio,
"max_drawdown": max_drawdown,
"pnl_return": pnl_return,
"avg_win": pos_stats.avg_win,
"avg_loss": pos_stats.avg_loss,
"max_win": pos_stats.max_win,
"max_loss": pos_stats.max_loss,
"avg_hold_duration_secs": pos_stats.avg_hold_duration_secs,
```

The live run path gets only tear sheet fields (sortino, calmar, max_drawdown, pnl_return) since
there are no `paper_run_positions` rows for live runs.

## Part B: Run comparison endpoint

### B1. New DAL function: `get_by_ids`

**File:** `crates/swym-dal/src/repo/paper_run.rs`

```rust
pub async fn get_by_ids(pool: &PgPool, ids: &[Uuid]) -> Result<Vec<PaperRunRow>, DalError> {
    sqlx::query_as::<_, PaperRunRow>(
        &format!("SELECT {COLS} FROM paper_runs WHERE id = ANY($1)")
    )
    .bind(ids)
    .fetch_all(pool)
    .await
    .map_err(Into::into)
}
```

### B2. New handler + types

**File:** `services/api/src/handlers/paper_runs.rs`

```rust
#[derive(Debug, Serialize)]
pub struct RunMetricsSummary {
    pub id: Uuid,
    pub status: PaperRunStatus,
    pub strategy_id: Option<Uuid>,
    pub starts_at: DateTime<Utc>,
    pub finishes_at: DateTime<Utc>,
    pub candle_interval: Option<String>,
    // flat metrics extracted from result_summary JSONB
    pub total_positions: Option<i64>,
    pub win_rate: Option<Decimal>,
    pub profit_factor: Option<Decimal>,
    pub net_pnl: Option<Decimal>,
    pub total_pnl: Option<Decimal>,
    pub total_fees: Option<Decimal>,
    pub sharpe_ratio: Option<Decimal>,
    pub sortino_ratio: Option<Decimal>,
    pub calmar_ratio: Option<Decimal>,
    pub max_drawdown: Option<Decimal>,
    pub pnl_return: Option<Decimal>,
    pub avg_win: Option<Decimal>,
    pub avg_loss: Option<Decimal>,
    pub max_win: Option<Decimal>,
    pub max_loss: Option<Decimal>,
    pub avg_hold_duration_secs: Option<f64>,
}
```

Extract from `result_summary` JSONB in Rust (not SQL). Note: `rust_decimal::Decimal` values
serialize as strings in JSON (e.g. `"1.42"`), so extraction uses `as_str().parse()` for Decimal
fields and `as_i64()` for integer fields.

Helper function:
```rust
fn extract_decimal(v: &serde_json::Value, key: &str) -> Option<Decimal> {
    v.get(key).and_then(|val| {
        val.as_str().and_then(|s| s.parse().ok())
            .or_else(|| val.as_f64().map(|f| Decimal::try_from(f).ok()).flatten())
    })
}
```

### B3. Register route

**File:** `services/api/src/main.rs`

```rust
.route("/api/v1/paper-runs/compare", get(handlers::paper_runs::compare_paper_runs))
```

Must appear BEFORE `/api/v1/paper-runs/{id}` to prevent Axum matching "compare" as a UUID.

## Part C: Update docs/api.md

**File:** `docs/api.md`

1. Update the `result_summary` field table with new flat fields
2. Add section documenting `GET /api/v1/paper-runs/compare`

## Files to modify

1. `crates/swym-dal/src/repo/paper_run_position.rs` — extend `PositionAggregateStats` + SQL
2. `services/paper-executor/src/runner.rs` — extract tear sheet metrics + include in JSON
3. `crates/swym-dal/src/repo/paper_run.rs` — add `get_by_ids()`
4. `services/api/src/handlers/paper_runs.rs` — add compare handler + types
5. `services/api/src/main.rs` — register `/compare` route
6. `docs/api.md` — document new fields + compare endpoint

## Verification

```bash
cargo check
cargo clippy

# After deploying, run a backtest and check enriched fields:
curl -s .../api/v1/paper-runs/<id> | jq '.result_summary | {
  net_pnl, sharpe_ratio, sortino_ratio, calmar_ratio, max_drawdown,
  avg_win, avg_loss, max_win, max_loss, avg_hold_duration_secs
}'

# Compare two runs:
curl -s ".../api/v1/paper-runs/compare?ids=<id1>,<id2>" | jq .
```
