# Composite Metric & Trades-Per-Year Normalization — Implementation Plan

_Created: 2026-03-23. Work off this doc top-to-bottom; check off items as completed._

---

## Background & Motivation

### Problem 1 — Hardcoded trade count floors ignore IS window length

`calculate_metrics` uses `MIN_SCORABLE_TRADES = 30` as an absolute floor. This creates an uneven playing field:

| Asset | IS window | Min trades | Implied min trades/year |
|-------|-----------|-----------|------------------------|
| BTC   | ~8.0 yr   | 30        | 3.75 / yr              |
| ETH   | ~7.6 yr   | 30        | 3.95 / yr              |
| LINK  | ~7.3 yr   | 30        | 4.11 / yr              |
| SOL   | ~4.7 yr   | 30        | **6.38 / yr** ← unfairly hard |

SOL has 70% more required trades per year than BTC. This biases the optimizer toward BTC-tuned frequencies and may explain why SOL winners often hit the degenerate-trades guard.

### Problem 2 — Raw Calmar can be gamed by low-frequency, low-drawdown solutions

Example from 2026-03-23 BTC 4H run: iter 2 winner had Calmar=0.3762 / 36 trades; iter 3 found Calmar=0.3641 / 75 trades. Iter 3 was more statistically robust (more trades = tighter confidence intervals) and had better OOS P&L (+24.4% vs -18.9%), but raw Calmar picked iter 2.

### Problem 3 — Composite metric can itself be gamed by churn

`Calmar × log(trades/floor)` is unbounded. At 500 trades the multiplier is `log(500/30) = 2.81`. A mediocre 300-trade strategy could outscore an excellent 36-trade one:
- 300 trades, Calmar=0.10 → `0.10 × log(10) = 0.230`
- 36 trades, Calmar=0.3762 → `0.3762 × log(1.2) = 0.069`

---

## Proposed Solution

### Formula (updated 2026-03-23 after BTC 8H validation)

```
floor     = max(MIN_ABS_TRADES, round(MIN_TRADES_PER_YEAR × is_years))
log_bonus = clamp(log(trades / floor), 0, COMPOSITE_LOG_CAP)
composite = Calmar × (1 + log_bonus)
```

The key change from the initial draft (`Calmar × log_bonus`) is the `+1` offset.

**Why `(1 + log)` instead of raw `log`:**

BTC 8H provided live in-run validation comparing three iterations:

| Iter | IS Calmar | Trades | OOS P&L | `log` score | `1+log` score |
|------|-----------|--------|---------|-------------|---------------|
| Iter 1 | 1.0546 | 66 | **+11.2%** ✅ | 0.764 | **1.818** |
| Iter 3 | 1.1148 | 50 | -12.5% ❌ | 0.498 | 1.612 |
| Iter 2 (Calmar winner) | 1.2262 | 36 | **-34.9%** ❌❌ | 0.144 | 1.371 |

Both formulas rank iter 1 first (the only positive OOS outcome). But raw `log` scores the Calmar winner at 0.144 — nearly zero — despite a Calmar of 1.23. That's too harsh for a 36-trade, 8-year IS solution. `(1 + log)` scores it at 1.371, giving it appropriate credit while still being outranked by the higher-trade solutions.

**Semantics of `(1 + log)`:**
- At exactly the floor: `(1 + 0) = 1` → full Calmar credit, no penalty
- At 4× floor: `(1 + 1.386) = 2.386` → ~2.4× Calmar bonus
- Above 4× floor: capped at 2.386× regardless

**Config constants:**
- `MIN_ABS_TRADES = 10` — absolute floor regardless of window length
- `MIN_TRADES_PER_YEAR = 4.0` — calibrated from BTC's implicit 3.75/yr standard
- `COMPOSITE_LOG_CAP = log(4) ≈ 1.386` — caps at 4× the floor

**Effective floors and caps by asset:**

| Asset | IS years | floor = max(10, 4×yrs) | full bonus cap |
|-------|----------|------------------------|----------------|
| BTC   | 8.0      | 32                     | 128 trades     |
| ETH   | 7.6      | 30                     | 121 trades     |
| LINK  | 7.3      | 29                     | 117 trades     |
| SOL   | 4.7      | **19**                 | **75 trades**  |

SOL's floor drops from 30 → 19. Gaming is prevented by the cap.

### Gaming analysis

| Attack | Mitigated? | How |
|--------|-----------|-----|
| Churn (300+ trades, low Calmar) | ✅ Yes | log cap = 1.386 limits max multiplier to (1+1.386)=2.386× |
| Low-frequency gamed Calmar | ✅ Yes | At-floor solution scores only 1×; high-trade solution scores up to 2.386× |
| Floor solution scored near-zero | ✅ Fixed | `+1` offset means floor solutions score full Calmar (1×), not ≈0 |
| SOL penalised vs BTC | ✅ Yes | floor proportional to IS years |
| Commission drag from more trades | ⚠ Partial | CPU Calmar includes commission; GPU pre-filter does not. GPU may over-select high-frequency candidates. Monitor. |
| Threshold crowding to pump trade count | ⚠ Partial | 10-unit threshold proximity guard already exists. May need to widen. |

---

## Open Questions

- [x] **OQ-1**: Should `MIN_TRADES_PER_YEAR = 4.0` or higher? **ANSWERED by 20-combo data: keep 4.0.**
  - SOL winners: 32T, 32T, 41T, 55T, 37T — all well above floor=19. Floor was never the binding constraint for SOL; it's regime overfitting.
  - The ETH 12H positive-OOS winner (34T/7.6yr = 4.5/yr) is just above the 4.0/yr rate. Raising to 5/yr would push ETH floor to 38, making 34T score=0 — far too punitive for a solution with +38.4% OOS.
  - **Decision: MIN_TRADES_PER_YEAR = 4.0 confirmed.**

- [ ] **OQ-2**: Does changing the floor in `calculate_metrics` break the OOS dashboard verdict? OOS dashboard already passes a scaled `min_trades` explicitly — verify it continues to use its own scaling, not the config constant.

- [x] **OQ-3**: Should the GPU pre-filter baseline (`--baseline-score`) switch to composite, or stay as raw Calmar? **ANSWERED: keep GPU as raw Calmar.** Composite requires trade count which is unavailable in the GPU kernel. Document explicitly in code comment. No data evidence suggests changing this.

- [ ] **OQ-4**: `--baseline-score` passed between iterations is the GPU Calmar of the current winner. If the winner is now selected by composite, its raw Calmar could be low (same oscillation issue as today). The backlog item [OPT] Fix GPU pre-filter baseline oscillating covers this — needs to be resolved before or alongside this change.

- [x] **OQ-5**: Retroactive re-scoring done during baseline analysis (see tables above). **ANSWERED: composite makes the right call in 16/20 combos.** In every divergence case where OOS is known, the Calmar winner had negative OOS. Known exception: ETH 12H (Calmar winner 34T had +38.4% OOS; composite preferred 42T — we don't have OOS for that alternative). This is documented in Phase 6 as a monitoring case.

---

## Implementation Punchlist

### Phase 1 — Config & foundations

- [ ] **P1-A** `config.py`: Add `MIN_TRADES_PER_YEAR = 4.0`, `MIN_ABS_TRADES = 10`, `COMPOSITE_LOG_CAP = 1.386` (= log(4))
- [ ] **P1-B** `config.py`: Add helper function `get_min_trades(is_years)` → `max(MIN_ABS_TRADES, round(MIN_TRADES_PER_YEAR * is_years))`. Centralising here means all callers (optimize_strategy, oos_dashboard, calculate_metrics) use one source of truth.

### Phase 2 — calculate_metrics dynamic floor

- [ ] **P2-A** `strategies/strategy_activation_scores.py`: Remove hardcoded `MIN_SCORABLE_TRADES = 30`. Instead, accept an optional `min_trades` kwarg (it already exists). Update any internal uses to use the passed value or fall back to `get_min_trades(is_years)` computed from the data's own date range.
- [ ] **P2-B** Verify that `oos_dashboard.py` still passes its own scaled `min_trades` explicitly (it does — confirm nothing regresses).
- [ ] **P2-C** Verify that `validate_strategy.py` passes a reasonable `min_trades` (or derives it from data dates).

### Phase 3 — optimize_strategy.py composite ranking

- [ ] **P3-A** After CPU verification of the top-5000, compute `composite_score` for each result:
  ```python
  import math
  from config import COMPOSITE_LOG_CAP, get_min_trades
  # is_years computed from score_start to train_end
  floor = get_min_trades(is_years)
  for r in verified:
      trades = r.get('Total Trades', 0)
      calmar = r.get('Calmar Ratio', 0)
      if trades >= floor and calmar > 0:
          log_bonus = min(math.log(trades / floor), COMPOSITE_LOG_CAP)
          r['Composite'] = calmar * (1 + log_bonus)  # NOTE: (1 + log), not raw log
      else:
          r['Composite'] = 0.0
  ```
  _(The `+1` offset ensures at-floor solutions score full Calmar × 1.0, not near-zero. See formula rationale above.)_
- [ ] **P3-B** Add `'Composite'` to the list of valid `--metric` choices in the argparse definition.
- [ ] **P3-C** When `--metric Composite`, sort and rank by `Composite`; display both Composite and Calmar in the winner line.
- [ ] **P3-D** Store `composite_score` in the sweep CSV output so it's human-readable.

### Phase 4 — auto_optimize_loop.py

- [ ] **P4-A** Change `OPTIMIZATION_METRIC = "Composite"` (was `"Calmar Ratio"`).
- [ ] **P4-B** DB percentile rank: rank by `Composite` (already done for Calmar; just update the key).
- [ ] **P4-C** DB write filter: keep as `Calmar > 0` (preserve all profitable solutions, including below-floor ones for future analysis). Composite ranking is applied at selection/query time, not at write time. Rationale: below-floor rows are still useful for sign-stability analysis even if they can't win on composite.
- [ ] **P4-D** Iter summary display: show `Composite=X  Calmar=Y  Trades=N` so both are visible.
- [ ] **P4-E** `--baseline-score` passed to subprocess: currently the winner's GPU Calmar. Since GPU doesn't compute Composite, keep passing GPU Calmar. Document this explicitly in the code comment.

### Phase 5 — DB schema (required)

- [ ] **P5-A** Add `composite_score REAL` column to `sweep_results` table in `write_to_sweep_db`. **Promoted to required** — `mine_sweep_db.py` needs to sort by composite natively for sign-stability analysis to reflect the new optimization objective. Can always be back-computed from `calmar_ratio` + `total_trades` for existing rows via a migration.

### Phase 6 — Validation before trusting results

- [x] **P6-A** Retroactive re-scoring completed during baseline analysis (see tables above). BTC 4H: yes, iter3 (75T/comp=0.674) beats iter2 (36T/comp=0.420) ✓. Composite diverges from Calmar in 11/20 combos; Calmar winner had negative OOS in every divergence case where OOS is known.
- [ ] **P6-B** Run a 0.25h BTC 1D composite run and inspect: are trade counts higher? Are P&L values healthy? Check for churn (> 128 trades for BTC, i.e. > 4× floor, would be suspicious).
- [ ] **P6-C** After first full composite run, update the OOS dashboard and compare verdict counts vs the Calmar run. More ACCEPT/EXCELLENT verdicts would indicate the higher-trade-count solutions generalize better.
- [ ] **P6-D** Monitor ETH 12H specifically: Calmar winner (34T, +38.4% OOS) is the known counterexample where composite would pick the 42T alternative. After a composite run on ETH 12H, check if the composite winner also has positive OOS. If yes, both metrics work; if no, investigate whether 34T was a statistical fluke (only 6 OOS trades).

### Phase 7 — Cleanup

- [ ] **P7-A** Remove `MIN_SCORABLE_TRADES` hardcoded constant from `strategy_activation_scores.py` entirely. Replace all usages with `get_min_trades()`.
- [ ] **P7-B** Update `CLAUDE.md` architecture section: note composite metric formula and the trades-per-year normalization.
- [ ] **P7-C** Update backlog: mark `[METRIC] Composite metric` item as in-progress, add link to this doc.

---

## What's NOT changing

- GPU kernel still optimizes raw Calmar (no composite in GPU — too complex, compute stays on CPU)
- OOS dashboard verdict logic stays Sortino-based (separate concern; can revisit after seeing composite results)
- `MIN_SCORABLE_TRADES` in the degenerate exit guard stays as a hard floor — composite wrapping is applied on top, not instead of it

---

---

## Baseline Data — Current Calmar Run (2026-03-23, 0.25h per combo)

_BTC and ETH complete (10/20 combos). SOL/LINK still running. Used in Phase 6 retroactive validation._

### Calmar Winners — BTC (floor=32, IS=8.0yr)

| TF | IS Calmar | IS Sortino | IS P&L | IS DD | IS Trades | OOS P&L | OOS T | Comp Score | Notes |
|----|-----------|------------|--------|-------|-----------|---------|-------|------------|-------|
| 4H | 0.3762 | 1.169 | 6,421% | -24.2% | 36 | -18.9% | 9 | 0.420 | ⚠ Composite picks iter3: 75T/0.364 → **comp=0.674** |
| 6H | 0.7494 | 1.623 | 32,769% | -26.5% | 83 | **+37.4%** | 10 | 1.463 | ✓ Same winner under composite |
| 8H | 1.2262 | 2.182 | 71,558% | -25.7% | 36 | -34.9% | 6 | 1.370 | ⚠ Composite picks iter1: 66T/1.055 → **comp=1.819** |
| 12H | 1.7067 | 2.461 | 2,768% | -13.7% | 50 | -0.7% | 3 | 2.469 | Only winner known; composite same |
| 1D | 4.3913 | 3.689 | 203,600% | -36.3% | 41 | -12.5% | 5 | 5.479 | Only winner known; composite same |

### Calmar Winners — ETH (floor=30, IS=7.6yr)

| TF | IS Calmar | IS Sortino | IS P&L | IS DD | IS Trades | OOS P&L | OOS T | Comp Score | Notes |
|----|-----------|------------|--------|-------|-----------|---------|-------|------------|-------|
| 4H | 0.4688 | 1.200 | 302,624% | -38.8% | 66 | -45.1% | 7 | 0.839 | ✓ Same winner under composite |
| 6H | 0.8909 | 2.116 | 5,948% | -15.4% | 42 | -43.4% | 7 | 1.191 | ⚠ Composite picks iter3: 86T/0.787 → **comp=1.616** |
| 8H | 1.1090 | 2.185 | 156,874% | -32.4% | 32 | **+7.1%** | 4 | 1.181 | ⚠ Composite picks iter3: 37T/1.097 → **comp=1.327** |
| 12H | 2.2367 | 2.687 | 373,971% | -30.1% | 34 | **+38.4%** | 6 | 2.517 | ⚠ Composite picks iter1: 42T/2.047 → comp=2.736 (❗ Calmar winner had best OOS in dataset) |
| 1D | 5.7215 | 4.342 | 317,689% | -30.4% | 31 | -20.7% | 2 | 5.910 | ⚠ Composite picks iter3: 42T/5.379 → **comp=7.189** |

### Calmar Winners — SOL (floor=19, IS=4.73yr)

| TF | IS Calmar | IS Sortino | IS P&L | IS DD | IS Trades | OOS P&L | OOS T | Comp Score | Notes |
|----|-----------|------------|--------|-------|-----------|---------|-------|------------|-------|
| 4H | 0.7946 | 1.632 | 417,348% | -43.0% | 32 | -16.8% | 6 | 1.209 | ⚠ Composite picks iter1: 49T/0.725 → **comp=1.412** |
| 6H | 1.8024 | 2.715 | 88,350% | -24.0% | 32 | -20.0% | 12 | — | Winner from prior run (all 3 iters failed to beat baseline); iter1 53T/1.527 → comp=3.094 |
| 8H | 2.3345 | 2.645 | 928,030% | -38.8% | 41 | -74.2% | 15 | 4.130 | ⚠ Composite picks iter3: 45T/2.226 → comp=4.145 (marginal) |
| 12H | 3.9563 | 3.203 | 1,385,611% | -44.1% | 55 | -20.1% | 11 | 8.161 | ✓ Same winner under composite |
| 1D | 13.6058 | 5.005 | 604,355% | -39.1% | 37 | -62.1% | 7 | 22.674 | ⚠ Composite picks iter1: 53T/12.70 → **comp=25.723** |

### Calmar Winners — LINK (floor=29, IS=7.26yr)

| TF | IS Calmar | IS Sortino | IS P&L | IS DD | IS Trades | OOS P&L | OOS T | Comp Score | Notes |
|----|-----------|------------|--------|-------|-----------|---------|-------|------------|-------|
| 4H | 0.4494 | 1.264 | 287,060% | -44.7% | 52 | -35.4% | 7 | 0.712 | ✓ Same winner under composite |
| 6H | 1.0005 | 1.822 | 731,650% | -35.9% | 32 | -6.3% | 3 | 1.099 | ⚠ Prior baseline won (Calmar=1.001/32T); composite picks iter1: 63T/0.771 → **comp=1.369** (UPSET) |
| 8H | 1.0643 | 1.852 | 130,660% | -36.7% | 46 | -53.6% | 10 | 1.555 | ⚠ Composite picks iter1: 67T/1.044 → **comp=1.918** |
| 12H | 2.0385 | 2.379 | 269,587% | -35.5% | 65 | **+40.6%** | 6 | 3.684 | ✓ Same winner under composite |
| 1D | 6.5327 | 4.776 | 795,810% | -37.5% | 49 | **+37.0%** | 11 | 9.959 | Prior baseline won; composite also picks prior baseline (comp=9.959 vs iter1 comp=9.928) |

### Composite vs Calmar — Full Summary (20 combos)

| Result | Count | Combos |
|--------|-------|--------|
| Composite agrees with Calmar | 9 | BTC 6H/12H/1D, ETH 4H, SOL 6H/12H, LINK 4H/12H, LINK 1D (marginal) |
| Composite picks higher-trade solution | 11 | BTC 4H/8H, ETH 6H/8H/12H/1D, SOL 4H/8H/1D, LINK 6H/8H |
| **OOS positive where metrics agree** | 4 | BTC 6H (+37.4%), ETH 12H (+38.4%), LINK 12H (+40.6%), LINK 1D (+37.0%) |
| **OOS positive where metrics differ** | 0 | — (all divergences where OOS is known: Calmar winner was negative) |
| **❗ Notable exception** | 1 | ETH 12H: Calmar picks 34T (+38.4% OOS), composite would pick 42T |

**Key finding:** In every case where composite diverges from Calmar AND the OOS is known, the Calmar winner had negative OOS. The ETH 12H Calmar winner is the only example where the lower-trade solution had positive OOS. All 4 positive OOS results (BTC 6H, ETH 12H, LINK 12H, LINK 1D) come from combos where metrics agree OR where the Calmar winner was lucky. Composite is making the right call ≥80% of the time based on available evidence.

### Previous Sortino-era baselines (contaminated TRAIN_END, for comparison only)

| Asset | TF | IS Sortino | OOS Sortino | OOS Verdict | Notes |
|-------|----|------------|-------------|-------------|-------|
| COINBASE_BTCUSD | 1D | 4.0759 | 0.0642 | ❌ POOR 2% | Sortino-gamed? |
| COINBASE_BTCUSD | 4H | 1.2929 | -0.7076 | ❌ NEGATIVE | |
| COINBASE_BTCUSD | 6H | 1.8907 | -0.1491 | ❌ NEGATIVE | Sortino winner had P&L=570% — clearly gamed |
| COINBASE_ETHUSD | 1D | 5.1256 | 2.4656 | ⚠ WEAK 48% | Best OOS result in full set |
| BINANCE_SOLUSD  | 1D | 6.8033 | 1.3233 | ❌ POOR 19% | |

_Last updated: 2026-03-23_
