# Spec: Activation Score Normalization

**Created:** 2026-03-13
**Status:** Draft — not started
**Estimated effort:** 1 day across multiple steps
**Roadmap entry:** "Score normalization: normalize activation score to fixed range so thresholds have stable meaning"

---

## Problem

The activation score is a raw dot-product of normalized features × weights:

```
score(t) = sum( feature_i(t) * weight_i )   for i in 0..24
```

Because `score` scales linearly with the magnitude of all weights combined, **weights and
thresholds are coupled**: doubling every weight doubles every score, and the thresholds
need to double too just to produce identical trade signals.

Consequences for optimization:
1. **Redundant search space** — there are infinitely many weight+threshold combinations
   that produce the exact same strategy behaviour (e.g., all weights × 2, all thresholds × 2).
   The optimizer wastes samples on these duplicates.
2. **Threshold ranges are meaningless** — the params file defines entry threshold as
   `start: 160, stop: 840`. Whether 160 is "tight" or "loose" depends entirely on what
   the current weight set produces for scores — and that changes with every sample.
   The optimizer has to discover the coupling relationship from scratch every run.
3. **LLM over-zooms on thresholds** — the AI sees thresholds of e.g. 51–71 in top
   results and narrows to that range, not realising those values only make sense with
   the specific weight magnitudes in that run.

---

## Proposed Solution: Theoretical-Maximum Normalization

Since all 25 features are normalised to approximately **[−1, +1]** by `_prepare_features`,
the theoretical maximum absolute score for a given weight set is:

```
max_score = sum( abs(weight_i) )   for i in 0..24
```

Dividing by this and scaling to 1000 gives a **normalised score** with stable semantics:

```
normalised_score(t) = raw_score(t) / max_score * 1000.0
```

| Normalised score | Meaning |
|---|---|
| +1000 | Every feature is perfectly aligned with its weight (maximum bullish) |
| 0 | Net zero alignment |
| −1000 | Every feature is perfectly anti-aligned (maximum bearish) |

With this, **thresholds always mean the same thing regardless of what weights are being
tested**. An entry threshold of 200 means "enter when score is 20% of maximum bullish"
for every parameter set in every run.

**Guard:** if `max_score == 0` (all weights are zero), skip normalisation and use raw
score (which will also be zero — no trades, correctly).

---

## Feature Bounds Audit

**Verified 2026-03-13** against `data/COINBASE_BTCUSD-1D.csv` (4087 bars).

| Col | Name | Formula | Actual min | Actual max | Notes |
|---|---|---|---|---|---|
| 0 | stoch_norm | `(stoch − 50) / 50` | −1.000 | +1.000 | ✓ bounded |
| 1 | macd_pred_norm | `sign(macd_pred)` | −1.000 | +1.000 | ✓ bounded |
| 2 | osc_norm | `(osc − 50) / 50` | −1.000 | +0.867 | ✓ bounded |
| 3 | macd_bullish_norm | `1 if nonzero else 0` | 0.000 | +1.000 | ✓ bounded; sparse (185/4087 bars) |
| 4 | m3_momentum_norm | `sign(m3_mom)` | −1.000 | +1.000 | ✓ bounded |
| 5 | m2_tiny_norm | `sign_epsilon(m2_tiny)` | −1.000 | +1.000 | ✓ bounded |
| 6 | rsid_osc_norm | `−(rsid − 42) / 28` | **−2.071** | **+1.500** | ⚠ EXCEEDS [−1,+1] — see note below |
| 7 | stoch_div_osc_norm | `sign3(stoch_div)` | −1.000 | +1.000 | ✓ bounded |
| 8 | vwap_div_osc_norm | `clip(vwap_div, −1, 1)` | −1.000 | +1.000 | ✓ bounded |
| 9 | stoch_peaking_norm | `−1 if peaking else 0` | −1.000 | 0.000 | ✓ bounded; 1291 bars |
| 10 | stoch_bottoming_norm | `+1 if bottoming else 0` | 0.000 | +1.000 | ✓ bounded; 436 bars |
| 11 | m3_div_osc_norm | `sign3(m3_div)` | −1.000 | +1.000 | ✓ bounded |
| 12 | bearish_engulfing | raw confidence [0,1] | 0.000 | +1.000 | ✓ bounded; sparse (90/4087) |
| 13 | m2_div_osc_noOffset_norm | `sign3(m2_nooff)` | −1.000 | +1.000 | ✓ bounded |
| 14 | m2_div_osc_norm | `sign3(m2_div)` | −1.000 | +1.000 | ✓ bounded |
| 15 | bullish_hammer | raw confidence [0,1] | 0.000 | +0.872 | ✓ bounded; sparse (231/4087) |
| 16 | bullish_engulfing | raw confidence [0,1] | 0.000 | +1.000 | ✓ bounded; sparse (83/4087) |
| 17 | shooting_star | raw confidence [0,1] | 0.000 | +0.774 | ✓ bounded; sparse (82/4087) |
| 18 | btc_spx_corr | `clip(corr, −1, 1)` | −0.592 | +0.778 | ✓ bounded (naturally limited by 30-bar window) |
| 19 | dxy_roc_norm | pre-normalised in Pine | −1.000 | +1.000 | ✓ confirmed bounded |
| 20 | vix_pctrank_inv | pre-normalised in Pine | 0.000 | +0.996 | ✓ confirmed [0,+1] |
| 21 | btc_dom_roc_sign | pre-normalised in Pine | −1.000 | +1.000 | ✓ confirmed bounded |
| 22 | us10y_roc_inv_sign | pre-normalised in Pine | −1.000 | +1.000 | ✓ confirmed bounded |
| 23 | spy_above_200ema | pre-normalised in Pine | −1.000 | +1.000 | ✓ confirmed bounded (signed, not 0/1) |
| 24 | gold_roc_pctrank | pre-normalised in Pine | 0.000 | +1.000 | ✓ confirmed [0,+1] |

**⚠ rsid_osc_norm out-of-bounds:** `−(rsid − 42) / 28` reaches −2.07/+1.50 because
`rsid_osc` can exceed the assumed [14, 70] range. Its weight in the winner params is
5.88, so the maximum contribution is 5.88 × 2.07 ≈ 12.2 vs expected 5.88 × 1.0 = 5.88.
This is a small error (< 0.1% of `max_score = 751.9`). **Mitigation for Step 2:** clip
`rsid_osc_norm` to [−1, +1] in `_prepare_features`. This also aligns the Python
computation more closely with what Pine's score formula assumes.

---

## Impact Scope

Changes required in four places:

| Location | Change | Risk |
|---|---|---|
| `strategies/strategy_activation_scores.py` — CPU `generate_signals` | Divide raw score by `max_score * 1000` before comparing to thresholds | Medium |
| `strategies/strategy_activation_scores.py` — GPU `_backtest_kernel` | Same division inside the CUDA kernel | Medium-High |
| `strategies/params_strategy_activation_scores_*.json` — all threshold params | Re-set entry/exit threshold ranges to the new [−1000, +1000] scale | Low |
| TradingView Pine Script — `activation_score_poc` | Add same normalisation so TV scores match Python | High (TV parity) |

The CPU path (`generate_signals`) and GPU path must produce identical normalised scores
or the optimizer will find params that only work on GPU — verified via
`tools/debug_gpu_cpu_discrepancy.py`.

---

## Implementation Steps

### Step 1 — Score Distribution Analysis (30 min, no code changes)

**Goal:** Understand the actual score range under current best params. Confirm macro
column bounds. Decide on target normalised scale (1000 is proposed; 100 is simpler).

**COMPLETED 2026-03-13. Results:**

Winner params: `max_score (sum|w|) = 751.92`

Raw score distribution (4087 bars of COINBASE_BTCUSD 1D):
- Range: −568.9 to +483.7
- Mean: −48.4, Std: 228.1

Normalised score distribution (`raw / 751.92 * 1000`):
- Range: **−756.6 to +643.3** (never reaches ±1000 in practice — features aren't all extreme at once)
- Mean: −64.4, Std: 303.4
- Percentiles: p25=−294, p50=−76, p75=+169, p90=+365, p95=+423

Score histogram shape: roughly bell-curve centred slightly below zero, with a
secondary bump in the +300 to +400 range (reflects the regime-dependent nature
of the score).

Current thresholds in normalised space:
- `entry_thr = 300` → **+399 normalised** — only 6.8% of bars are above entry threshold
- `exit_thr  = 275` → **+366 normalised** — only 10.0% of bars are above exit threshold
- `conf_thr  = 44.54` → **+59 normalised** — 34.8% of bars are above confirmation threshold

**Checkpoint conclusion:** Yes, the thresholds make sense — the strategy enters at
very high conviction (top 7% of bars) and confirms exits at near-neutral score (+59).
The current ranges in the params file (entry 160–840, exit 50–725) make much more
sense in normalised space: most of the usable range is 0–600 normalised.

See the Feature Bounds Audit section for macro column verification results.

**Deliverable:** Feature bounds table verified (above). Recommended threshold ranges
for Step 4 derived from this analysis (see Step 4 below).

---

### Step 2 — CPU Path Normalisation (1–2 hours)

**COMPLETED 2026-03-13.**

**Changes made:**
- `strategies/library_activation_scores.py` line 175: clipped `rsid_osc_norm` to [−1, +1]
  using existing `clip_scalar()` helper (same pattern as `vwap_div_osc_norm`)
- `strategies/strategy_activation_scores.py` `_prepare_features` line 490: added
  `np.clip(...)` to rsid_osc_norm (for GPU path consistency — see Step 3)
- `strategies/strategy_activation_scores.py` `generate_signals`: added normalisation
  block after macro signals are added and before `df['activation_score'] = scores`:
  ```python
  max_score = abs(w_stoch) + abs(w_macd_pred) + ... (all 25 weights)
  if max_score > 0:
      scores = scores / max_score * 1000.0
  df['activation_score'] = scores
  ```

**Checkpoint results:**

Normalisation invertibility confirmed: back-converting normalised scores
(`score × max_score / 1000`) recovers `[-568.92, +483.71]` — exactly matching
the Step 1 raw score range. The normalisation is a pure scale transform.

Score distribution in normalised space: `min=−756.6, max=+643.3, mean=−63.9, std=303.6`
(matches Step 1 analysis exactly — confirms the path is working correctly).

Trade count with normalised thresholds (entry=398.98, exit=365.73, conf=59.23): **22 trades**
vs winner CSV reference of **20 trades**.

The 2-trade difference is from the rsid clipping fix, not from normalisation. The
clipping corrects bars where rsid_osc was outside [14–70] (the assumed range), changing
their score slightly. This shifts one or two crossunder events. This is expected and
correct — the pre-fix scores were slightly inaccurate on those bars.

**The winner CSV is now stale** — it was found with the old (un-clipped, un-normalised)
path. The params are still good starting points, but the metrics will differ slightly
when re-evaluated. A new winner will be found in Step 6.

---

### Step 3 — GPU Kernel Normalisation (1–2 hours)

**Goal:** Add the same normalisation inside `_backtest_kernel`.

**COMPLETED 2026-03-13.**

**Changes made** in `_backtest_kernel` (`strategy_activation_scores.py`):

1. After reading thresholds/configs — compute `max_score` once per thread:
   ```python
   max_score = 0.0
   for f in range(F):
       w = weights[idx, f]
       max_score += w if w >= 0.0 else -w
   if max_score == 0.0:
       max_score = 1.0  # guard: all-zero weights
   ```
2. Bar-0 initialisation — normalise `prev_score` after the dot-product loop:
   ```python
   prev_score = prev_score / max_score * 1000.0
   ```
3. Per-bar loop — normalise `score` after the dot-product loop:
   ```python
   score = score / max_score * 1000.0
   ```

**Checkpoint results:**

Tested with winner weights and normalised thresholds (entry=398.98, exit=365.73, conf=59.23)
on full COINBASE_BTCUSD-1D dataset (4087 bars):

| Metric | CPU | GPU | Match? |
|---|---|---|---|
| Total Trades | 33 | 33 | ✓ |
| Max Drawdown % | 20.331 | 20.331 | ✓ |
| Total P&L % | 47977.19 | 47871.44 | ≈ (Δ 0.22%) |

Trades and MaxDD are identical — confirming crossunder signal logic is identical between
CPU and GPU with normalised scores.

The ~0.22% P&L difference is a **pre-existing commission application difference** unrelated
to normalisation: the CPU path subtracts commission from the strategy return
(`strat_ret -= 0.005`) while the GPU multiplies equity directly (`equity *= 0.995`).
For each commission event, the CPU computes `(1+r−c)` while the GPU computes `(1+r)(1−c)`;
difference is `r×c ≈ 0.005 × 0.005 = 0.000025` per event, cumulated over 66 events
times large equity. This was always present and is a separate issue (not introduced by
normalisation).

**Step 3 PASSED.** Normalisation in the GPU kernel is confirmed correct.

---

### Step 4 — Update Threshold Ranges in Params Files (30 min)

**COMPLETED 2026-03-13.**

All four params files updated with normalised-space threshold ranges:

| Param | Old range (raw scale) | New range (normalised [−1000,+1000] space) |
|---|---|---|
| `i_long_entry_activation_threshold` | various | 0 to +700 step 35 (20 values) |
| `i_long_exit_activation_threshold` | various | −200 to +600 step 40 (20 values) |
| `i_long_exit_activation_confirmation_threshold` | various | −300 to +400 step 35 (20 values) |

**Files updated:**
- `strategies/params_strategy_activation_scores_1D.json` (template)
- `strategies/params_strategy_activation_scores_8H.json` (template)
- `strategies/params_strategy_activation_scores_COINBASE_BTCUSD_1D.json`
- `strategies/params_strategy_activation_scores_COINBASE_BTCUSD_8H.json`

The same ranges apply to both timeframes — normalisation makes thresholds
weight-independent, so timeframe-specific threshold tuning is no longer needed
for initial exploration.

**Checkpoint results:**

Winner equivalent thresholds (entry=398.98, exit=365.73, conf=59.23) confirmed
inside all three new ranges. Strategy run with score_start=2019-01-01:

- Total Trades: **22** (within expected 20–30 range ✓)
- Calmar: 3.50 | Sortino: 3.27 | Max DD: −20.3%
- 6.9% of bars above entry threshold — confirms the strategy selects high-conviction entries
- 10.1% of bars above exit threshold — tight exit band just below entry band

**Step 4 PASSED.**

---

### Step 5 — TradingView Pine Script Update (1–2 hours)

**Goal:** Add the same normalisation to the Pine script so TV and Python scores remain
in parity.

**Approach in Pine:**
```pine
// After computing raw_score...
float max_score = math.abs(i_w_stoch) + math.abs(i_w_macd_pred) + ... // all 25 weights
float normalised_score = max_score > 0 ? raw_score / max_score * 1000.0 : 0.0
```

The Pine input thresholds (`i_long_entry_activation_threshold` etc.) then live in
the same [−1000, +1000] space and accept the values from Step 4.

**Checkpoint:** Re-export TV data and run `tools/compare_tv_trades.py`. The trade
match rate (currently ~85%) should be maintained or improved.

**Note:** This step requires manually editing the Pine script in TradingView and
re-exporting data. It cannot be automated. Do this step as its own session.

---

### Step 6 — Validation Run (1 hour setup + overnight run)

**Goal:** Run the optimizer with normalised scores and confirm it performs better
than before (finds higher Calmar, more stable threshold values across runs).

```bash
python auto_optimize_loop.py --hours 6 --data data/COINBASE_BTCUSD-1D.csv
```

**What to look for:**
- The top-1000 sweep results should show threshold values that are consistent across
  runs (no more 10× variation in threshold values between top results)
- Calmar Ratio of winner should be ≥ current best
- Spearman correlation of thresholds with Calmar should be stronger than before
  normalization (thresholds now mean something, so they should predict performance better)

---

## Risks and Mitigations

| Risk | Likelihood | Mitigation |
|---|---|---|
| CPU/GPU normalisation mismatch | Medium | Step 3 checkpoint (debug_gpu_cpu_discrepancy.py) |
| TV parity broken by Pine normalisation | Medium | Step 5 checkpoint (compare_tv_trades.py) |
| `max_score = 0` divide-by-zero | Low | Explicit guard (see Step 3 code snippet) |
| rsid_osc_norm exceeds [−1, +1] if rsid outliers exist | Low | Step 1: check actual rsid range in CSV; clip if needed |
| Existing winner params become invalid | Certain | After Step 4, old winner thresholds are in wrong scale; run Step 6 to find new winner |

---

## Decision Gate: Is Step 5 (TV update) Required?

Step 5 is the highest-friction step (manual Pine edit + re-export). It can be deferred
if the immediate goal is just improving optimization:

- **Skip Step 5** if you want faster optimization only — the optimizer finds better params,
  but those params cannot be directly applied to TradingView without converting
  thresholds back to unnormalized units.
- **Do Step 5** if you want end-to-end consistency — the same threshold values work in
  both the optimizer and TradingView.

Recommendation: do Steps 1–4 and 6 first. Revisit Step 5 once the optimizer is
producing good normalised params.
