# Spec: Time-Based Exit

**Created:** 2026-03-13
**Status:** Draft
**Estimated effort:** 0.5 day
**Roadmap motivation:** Prevent capital being locked in stalled trades; increase effective trade frequency without changing the entry logic.

---

## Problem

The current strategy holds a long position until the activation score crosses back below
the exit threshold (or a trailing stop fires). If price enters a sideways consolidation
after entry — neither rising enough to trail out nor falling enough to trigger the score
crossunder — the trade can sit open for weeks or months generating near-zero return while
tying up all capital and blocking any new entries.

This is a specific failure mode: the entry condition fires correctly (score drops through
entry threshold, signalling a transition), but the anticipated move simply does not
materialise. The strategy has no mechanism to admit this and move on.

**Example failure pattern on 1D:**
- Entry fires on bar 0 at $30,000
- Score stays below entry threshold for 60 bars (no re-entry possible)
- Close on bar 60 is $30,500 (+1.7%)
- Exit fires on bar 61 due to score crossunder at $29,800 (−0.7% net)
- Net result: 61 bars of capital tied up for a −0.7% trade

A time-based exit on bar N (say 20) would have freed capital on bar 20, and the entry
signal may have re-fired during the subsequent price action.

---

## Proposed Solution

Add two new optimisable parameters:

| Parameter | Type | Description |
|---|---|---|
| `i_time_exit_bars` | int | Number of bars after entry after which the time exit is eligible to fire. 0 = disabled. |
| `i_time_exit_min_gain_pct` | float | Minimum % gain (as a fraction, e.g. 0.03 = 3%) required at bar N to **suppress** the time exit. If the trade is up by at least this amount, the exit does NOT fire — the trade is working and should be allowed to continue. |

**Logic (pseudocode):**

```
if in_position:
    bars_held = current_bar - entry_bar
    if bars_held >= i_time_exit_bars > 0:
        gain = (close - entry_close) / entry_close
        if gain < i_time_exit_min_gain_pct:
            exit_signal = True   # stalled — exit
```

The time exit is **additive** to the existing exit conditions — it fires independently of
the score-based crossunder and trailing stop. Whichever fires first exits the trade.

### Design decisions

**Why a gain threshold rather than a simple N-bar timer?**
A pure timer exits winners and losers equally. If a trade is up 15% on bar 20 it is
clearly working; exiting it would be harmful. The gain threshold provides a minimum
bar that says "if you have moved in our favour by at least X%, keep holding." Only
stalled/losing trades are evicted.

Setting `i_time_exit_min_gain_pct = 0.0` degenerates to a pure N-bar timer (always exit
after N bars regardless). This is a valid special case the optimizer can explore.

**What counts as "stalled"?**
Close price vs entry close price (the close of the entry bar). We do not use MFE (max
favourable excursion) because the GPU kernel does not track MFE, and it would introduce
a survivorship-bias asymmetry: a trade that spiked to +15% and gave it all back would
not be considered stalled even though it is now flat.

**Interaction with trailing stop:**
If a trailing stop is active, it will typically fire before a 20-bar stall check on a
trade that has given back a significant high-water mark. The two mechanisms complement
each other: trailing stop handles reversals from a high, time exit handles flat/slow
entries that never got going. There is no conflict.

**Interaction with score-based exit:**
The time exit fires if the score-based exit has NOT already fired within N bars.
If the score exit fires on bar 5, the time check on bar 20 never matters.

---

## Suggested Parameter Ranges (for optimizer)

```json
"i_time_exit_bars": {
    "values": [0, 5, 10, 15, 20, 30]
},
"i_time_exit_min_gain_pct": {
    "start": 0.00,
    "stop":  0.10,
    "step":  0.02
}
```

`0` in `i_time_exit_bars` disables the feature entirely, allowing the optimizer to
discover whether it helps at all.

For 8H data, equivalent bar counts are roughly 3× the 1D values (8H bars per day = 3),
so the same `values` list works but represents shorter calendar time.

---

## Implementation Plan

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

Inside `_apply_trailing_stop` or in the `else` branch (no trailing stop), after the
existing entry/exit logic, add:

```python
# Time-based exit
time_exit_bars = int(params.get('i_time_exit_bars', 0))
time_exit_min_gain = float(params.get('i_time_exit_min_gain_pct', 0.0))
```

The cleanest integration is to modify `_apply_trailing_stop` (which already handles
bar-by-bar simulation) to also accept `time_exit_bars` and `time_exit_min_gain`, and
track `entry_close` and `entry_bar` during the loop. When no trailing stop is active,
`calculate_positions` is used — this would need to be replaced with a new bar-by-bar
path when `time_exit_bars > 0`, or `_apply_trailing_stop` could be generalised to
`_simulate_positions` that handles all bar-by-bar exit variants.

New variables tracked in the simulation loop:
- `entry_close: float` — close price on the entry bar
- `bars_held: int` — incremented each bar while in position, reset to 0 on exit

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

Add two new entries to the `thresholds` array (currently shape `(N, 4)`, grows to
`(N, 6)`):
- `thresholds[idx, 4]` → `time_exit_bars` (int stored as float; cast with `int()` in kernel)
- `thresholds[idx, 5]` → `time_exit_min_gain_pct`

In the kernel loop, add:
```
if in_position:
    bars_held += 1
    if time_exit_bars > 0 and bars_held >= time_exit_bars:
        gain = (close[t] - entry_close) / entry_close if entry_close != 0.0 else 0.0
        if gain < time_exit_min_gain:
            exit_signal = True
```

Track `entry_close` (set to `close[t]` on the entry bar) and `bars_held` (reset on exit).

**CRITICAL:** The `thresholds` array shape change must be matched in
`strategies/optimize_strategy.py` where it is constructed. Currently it is built as a 4-column
array; it must become 6 columns.

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

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

```pine
i_time_exit_bars        = input.int(0,   "Time Exit: Max Bars Held (0=off)", group=group_neural_activation_thresholds, display=display.none)
i_time_exit_min_gain_pct = input.float(0.03, "Time Exit: Min Gain to Hold (fraction)", step=0.01, group=group_neural_activation_thresholds, display=display.none)
```

Add the exit condition before or alongside `longExitCondition`:

```pine
var int entry_bar_index = na
if strategy.position_size > 0 and strategy.position_size[1] <= 0
    entry_bar_index := bar_index
var float entry_close_price = na
if strategy.position_size > 0 and strategy.position_size[1] <= 0
    entry_close_price := close

timeExitCondition = false
if i_time_exit_bars > 0 and strategy.position_size > 0 and not na(entry_bar_index)
    bars_held = bar_index - entry_bar_index
    gain      = (close - entry_close_price) / entry_close_price
    if bars_held >= i_time_exit_bars and gain < i_time_exit_min_gain_pct
        timeExitCondition := true

longExitCondition := longExitCondition or timeExitCondition
```

### Step 4 — Add parameters to `params_strategy_activation_scores_1D.json` (template)

```json
"i_time_exit_bars": {
    "values": [0, 5, 10, 15, 20, 30]
},
"i_time_exit_min_gain_pct": {
    "start": 0.00,
    "stop":  0.10,
    "step":  0.02
}
```

Propagate to the 8H template and asset-specific files.

### Step 5 — Verify CPU/GPU parity

Run `tools/debug_gpu_cpu_discrepancy.py` with parameters that include a non-zero
`i_time_exit_bars`. Confirm trade count and P&L match between CPU and GPU paths.

---

## Acceptance Criteria

- [ ] With `i_time_exit_bars = 0`, behaviour is identical to current (zero regression)
- [ ] With `i_time_exit_bars = 10, i_time_exit_min_gain_pct = 0.0`, every trade exits
      after exactly 10 bars regardless of gain
- [ ] With `i_time_exit_bars = 10, i_time_exit_min_gain_pct = 0.05`, trades up more
      than 5% on bar 10 are NOT exited by the time rule
- [ ] GPU and CPU produce identical trade counts and P&L on the same parameter set
- [ ] Pine Script produces matching trade signals when compared against Python on the
      same OHLCV data
- [ ] `i_time_exit_bars` appears in the cheatsheet/pine_snippet output files

---

## Open Questions

1. **Per-timeframe defaults:** Should the optimizer use different `i_time_exit_bars`
   ranges for 1D vs 6H vs 8H? 20 bars on 1D is ~1 month; on 6H it is ~5 days.
   The optimizer will naturally find the right value for each asset-specific params file,
   but the template defaults should probably note the calendar-time equivalence.

2. **Interaction with MIN_TRADES:** Adding time exits will increase trade count. Verify
   that the 30-trade minimum is still appropriate or should be recalibrated upward.
