# Spec: Multi-Timeframe Regime Filter

**Created:** 2026-03-13
**Status:** Draft
**Estimated effort:** 1–2 days
**Roadmap motivation:** Increase trade quality and frequency by gating entries with a macro regime signal, preventing the strategy from entering against dominant trend conditions.

---

## Problem

The activation score is computed bar-by-bar and has no memory of the broader trend
context. This means the entry crossunder can fire during a macro bear market (e.g.
mid-2022 BTC crash from $40k to $16k) in the same way it fires during a bull market
consolidation. The quality of those entries is fundamentally different but the strategy
treats them identically.

Two consequences:

1. **Losing entries during bear regimes.** The crossunder fires, the trade is entered,
   but the broader downtrend resumes and cuts the trade for a loss. Many of the strategy's
   losing trades cluster in sustained downtrend periods.

2. **Missed re-entries during bull regimes.** After a time-based or trailing-stop exit
   during a strong bull market, the macro regime is still clearly bullish but the
   activation score may take many bars to re-cross the entry threshold, delaying
   re-entry and missing a large portion of the move.

A higher-timeframe (weekly or monthly) regime filter would solve both: suppress entries
when the macro trend is bearish, and potentially lower the entry threshold when it is
strongly bullish.

---

## Why This Framework is Well-Positioned for This

The M2/M3 money supply signals already act as an implicit macro filter — M2 momentum
positive is a multi-month / multi-year signal. But they are weighted alongside 24 other
features in the activation score, so a large negative M2 signal can be overridden by
bullish short-term signals. An explicit regime gate is a hard constraint rather than a
soft contribution.

---

## Proposed Solution: Slow-Rolling Activation Score as Regime Signal

Rather than introducing a fully separate weekly-timeframe signal (which would require
either re-exporting TV data at a second timeframe or implementing true multi-timeframe
resampling), the simplest viable approach within the existing architecture is a
**slow-rolling average of the activation score itself**.

The activation score already encodes the confluence of all 25 indicators. A rolling
average of it over W bars gives a "regime-smoothed" version that:
- Removes bar-to-bar noise
- Captures the multi-week/multi-month trend direction
- Requires zero new data — it is derived entirely from existing data

**Regime condition:**

```
regime_score(t) = mean( activation_score[t-W+1 ... t] )

entry fires only if:
    (1) normal crossunder entry condition is true
    AND
    (2) regime_score(t) > i_regime_entry_min_score
```

Optionally, for a bullish-boost mode:

```
    (3) if regime_score(t) > i_regime_strong_bull_score:
            lower the effective entry threshold to i_long_entry_threshold_bull
```

The regime filter only gates entries (not exits). Exits always fire normally — if you
are already in a trade when the regime turns bearish, the exit logic handles it.

---

## Parameter Design

| Parameter | Type | Description |
|---|---|---|
| `i_regime_window` | int | Rolling window in bars for computing the regime score (e.g. 7 = ~weekly on 1D). 0 = disabled. |
| `i_regime_entry_min_score` | float | Minimum regime score required to allow a new entry. In the normalised [−1000, +1000] space. |

### Suggested ranges for optimizer

```json
"i_regime_window": {
    "values": [0, 5, 7, 10, 14, 21]
},
"i_regime_entry_min_score": {
    "start": -200.0,
    "stop":   400.0,
    "step":    50.0
}
```

`i_regime_window = 0` disables the filter entirely, allowing the optimizer to discover
whether it helps at all and preserving zero-regression baseline.

**Timeframe calendar equivalence:**

| Window | 1D calendar | 6H calendar | 8H calendar |
|---|---|---|---|
| 5 | ~1 week | ~1.25 days | ~1.7 days |
| 7 | ~1.5 weeks | ~1.75 days | ~2.3 days |
| 14 | ~2 weeks | ~3.5 days | ~4.7 days |
| 21 | ~1 month | ~5 days | ~7 days |

For 6H/8H data, a regime window of 21 bars is only one calendar week — likely too short
to act as a macro filter. Asset-specific params files should use larger windows for
higher-frequency data.

---

## Implementation Plan

### Step 1 — Python: `generate_signals` in `strategy_activation_scores.py`

After the activation score is normalised (the `df['activation_score'] = scores` line),
compute the regime score and gate the entry signal:

```python
regime_window = int(params.get('i_regime_window', 0))
regime_min    = float(params.get('i_regime_entry_min_score', -1000.0))

if regime_window > 0:
    regime_score = df['activation_score'].rolling(window=regime_window, min_periods=1).mean()
    regime_ok = regime_score > regime_min
else:
    regime_ok = pd.Series(True, index=df.index)  # always allow
```

Then modify the entry signal:

```python
entry_raw = entry_raw & regime_ok
```

This is the complete CPU-side change — a rolling mean and a boolean mask.

**Note:** The rolling mean introduces a `regime_window - 1` bar warm-up period at the
start of the dataset where the mean is computed on fewer bars. `min_periods=1` handles
this by using whatever bars are available. This is consistent with how Pine Script
handles `ta.sma()` with insufficient bars.

### Step 2 — GPU kernel: `_backtest_kernel`

The GPU kernel computes scores bar-by-bar and does not have access to a precomputed
rolling average. There are two options:

**Option A (recommended): Pre-compute regime signal on CPU, pass as feature column**

Before uploading features to GPU, compute the regime score series on CPU and add it as
an extra column to the feature matrix. Since the regime score depends on weights (it is
a rolling mean of the normalised activation score), this cannot be pre-computed without
knowing the weights.

This is the core challenge: the regime score is a **function of the current weight set**,
so it cannot be pre-computed in `_prepare_features` (which is weight-independent).

**Option B: Compute rolling sum in the GPU kernel**

Track a running sum of the last W scores inside the kernel. This requires a fixed-size
circular buffer or an approximation. For small W (≤ 21), a circular buffer is feasible
but adds significant complexity to an already dense CUDA kernel.

**Option C (pragmatic): Implement regime filter in CPU verification only; skip in GPU kernel**

GPU results are always CPU-verified before being recorded as winners. If the GPU kernel
ignores the regime filter (or approximates it as always-on), the CPU verification step
will correctly re-score using the full regime logic. The GPU sweep will find more
candidate winners (including some false positives), but the top-100 CPU verification
pass filters those out.

This means the GPU optimises for a slightly different objective (no regime filter), but
the final winners are always regime-filtered. The cost is that the GPU explores more
irrelevant parameter space. Given the current verification-on-top-100 design this is
acceptable for an initial implementation.

**Recommendation:** Implement Option C first. Option B can be added later if GPU/CPU
agreement degrades significantly.

For Option C, add a note in the GPU kernel comments:

```python
# NOTE: regime filter (i_regime_window) is NOT applied in the GPU kernel.
# CPU verification in verify_top_results applies the full filter.
# GPU acts as a pre-filter; final scores are from CPU path.
```

### Step 3 — Pine Script: `strategy_activation_scores.pine`

Add two new inputs in the "Neural Activation Thresholds" group:

```pine
i_regime_window          = input.int(0,     "Regime Filter: Window (0=off)",       group=group_neural_activation_thresholds, display=display.none)
i_regime_entry_min_score = input.float(-1000.0, "Regime Filter: Min Score for Entry", group=group_neural_activation_thresholds, display=display.none)
```

Compute the regime score:

```pine
regime_score = i_regime_window > 0 ? ta.sma(activation_score_poc, i_regime_window) : -1000.0
regime_ok    = i_regime_window == 0 or regime_score > i_regime_entry_min_score
```

Gate the entry condition:

```pine
longCondition := longCondition and regime_ok
```

This is the complete Pine change. `ta.sma` handles warm-up bars identically to
Python's `rolling(min_periods=1).mean()`.

### Step 4 — Add parameters to template params files

```json
"i_regime_window": {
    "values": [0, 5, 7, 14, 21]
},
"i_regime_entry_min_score": {
    "start": -200.0,
    "stop":   400.0,
    "step":    50.0
}
```

For 6H/8H templates, use larger window values to represent meaningful calendar time:

```json
"i_regime_window": {
    "values": [0, 14, 21, 42, 63]
}
```

(42 bars on 6H ≈ 10.5 days ≈ 1.5 weeks; 63 bars ≈ 2 weeks)

### Step 5 — Verify CPU parity

Run `tools/debug_gpu_cpu_discrepancy.py` with `i_regime_window = 7` and a non-trivial
`i_regime_entry_min_score`. Confirm that:
- Trade count changes (some entries are correctly suppressed)
- CPU-path results are internally consistent across multiple calls

Since the GPU does not implement the regime filter (Option C), do **not** compare GPU vs
CPU trade counts directly for this parameter — only compare P&L and trade patterns on
CPU-only runs.

---

## Expected Behaviour Changes

| Condition | Before | After |
|---|---|---|
| Entry fires during bear regime (regime_score < min) | Trade entered | Suppressed |
| Entry fires during bull regime (regime_score > min) | Trade entered | Trade entered (unchanged) |
| Active trade when regime turns bearish | Held until exit signal | Held until exit signal (unchanged — filter is entry-only) |
| Optimizer with `i_regime_window=0` | Current behaviour | Identical to current |

The filter is expected to:
- Reduce total trade count slightly (entries suppressed in bear regimes)
- Improve average trade quality (fewer bear-market entries)
- Have minimal effect during extended bull markets (regime score stays positive)

---

## Relationship to Existing M2/M3 Signals

The M2/M3 signals are already in the activation score as weighted features. The regime
filter is **not** a replacement for them — it operates at a different level:

| Signal type | How it works | What it captures |
|---|---|---|
| M2/M3 as activation score features | Soft contribution, weighted alongside 24 others | Bar-by-bar M2/M3 direction |
| Regime filter on activation score | Hard gate on entry | Multi-week smoothed confluence of ALL 25 features |

The regime filter is complementary: it asks "is the entire indicator system trending
bullish over the last W bars?" rather than "is M2 positive today?"

---

## Acceptance Criteria

- [ ] With `i_regime_window = 0`, behaviour is identical to current (zero regression)
- [ ] With `i_regime_window = 7, i_regime_entry_min_score = 200`, entries are suppressed
      on bars where `rolling_7_mean(activation_score) <= 200`
- [ ] Verifiable on a specific date: pick a known bear market bar, confirm entry does
      not fire when regime is below threshold
- [ ] Pine Script and Python produce matching entry suppression on the same test bar
- [ ] `i_regime_window` and `i_regime_entry_min_score` appear in cheatsheet/pine_snippet output

---

## Open Questions

1. **GPU option B implementation:** If GPU/CPU agreement degrades noticeably because the
   GPU is exploring too many regime-filter false positives, a circular-buffer rolling sum
   in the CUDA kernel should be evaluated. Complexity cost: ~20 lines of kernel code, a
   fixed `MAX_REGIME_WINDOW = 63` constant, and a small per-thread array.

2. **Exit regime filter:** Should the regime filter also suppress exits during a strong
   bull regime (i.e., never exit when the macro is strongly positive)? This would be a
   separate parameter (`i_regime_exit_max_score`) and is intentionally out of scope for
   the initial implementation — it introduces significant complexity and the trailing stop
   already handles holding winners.

3. **Timeframe-specific defaults:** The template files currently apply to all timeframes.
   The `i_regime_window` ranges that make sense differ significantly between 1D and 6H.
   The asset-specific params files (e.g. `COINBASE_BTCUSD_6H.json`) should have their own
   tuned ranges; the template files should document the calendar-time equivalence table
   from this spec.

4. **Interaction with time-based exit:** If a regime filter suppresses re-entries during
   a weak regime, a stalled trade that time-exits will have fewer re-entry opportunities.
   This is the desired behaviour (don't re-enter in a weak regime), but verify that the
   combined parameter set doesn't result in pathologically low trade counts.
