# Execution recovery (2026-07-18) — when you fill is the edge you leave on the table

Report: `reports/execution_recovery_matrix.json` / `.md`  
Follows Phase A A4 ([phase-a-matrix.md](phase-a-matrix.md)).  
Arms: `close_control` · `next_open` · `stale_close` · `next_open_gap2`.

Mechanics verified before sign-off: `stale_close` re-anchors stop to
fill − 1.5×ATR(signal day); no same-day stop after a close fill; each t−1
candidate keeps its own gate/rank cross-section (no lookahead). Numbers trusted.

---

## Results

| Arm | Full Sharpe | Full CAGR | Full DD | Train Sharpe | Val Sharpe |
|---|---:|---:|---:|---:|---:|
| **close_control** | **1.77** | **48.3%** | **21.6%** | 1.75 | 1.68 |
| **stale_close** | **1.57** | **40.6%** | **21.8%** | 1.36 | **1.74** |
| next_open | 1.20 | 30.0% | 28.2% | 0.94 | 1.44 |
| next_open_gap2 | 1.06 | 25.2% | 27.8% | 0.72 | 1.41 |

### Monotonicity (the strongest evidence)

In every window, Sharpe recovers as the fill moves **toward** the signal:

| Window | next_open → stale_close → close |
|---|---|
| Train | 0.94 → 1.36 → 1.75 |
| Validation | 1.44 → 1.74 → 1.68* |
| Full | 1.20 → 1.57 → 1.77 |

\*Val close 1.68 is within SE of stale 1.74 — not a break of the story.

A dead edge does **not** produce a monotone gradient in fill timing. A live edge
that is progressively surrendered to execution delay does. Combined with full-period
**DD recovery** (28.2% → 21.8% ≈ close’s 21.6%), this confirms the diagnosis:
**when you fill**, not decaying alpha.

### Auto-flag `recover: False` is not a null

Pre-registered recovery applied the “≥ close − 0.5 SE” bar to **`stale_close`**,
the **lower-bound** arm (one full session of lag). Flags:

| Flag | Result |
|---|---|
| near_close_control (val) | **True** (Δ +0.06) |
| beats_next_open | **True** (val Δ +0.30) |
| train_ok | **False** (1.36 vs need ≥ ~1.39 — miss by ~0.03) |

The floor missed “≥ close − 0.5 SE” by 0.03 while the live design is expected to
sit **above** the floor. The flag worked correctly on the wrong object.

**Log sentence:** *Partial recovery proven; full recovery needs same-day fill.*

### Live outcome bracket

| Bound | Arm | Meaning |
|---|---|---|
| Floor | stale_close ~1.57 full | One-session-stale close fill (conservative) |
| Ceiling | close_control ~1.77 full | Same-day close; optimistic only by final ~15 min of signal info |

Real near-close execution (scan ~15:30–15:40 ET on a ~99% complete bar, MOC by
15:50/15:55) is signal-at-partial-bar filled at the same close the control uses.
**Live truth is bracketed [1.57, 1.77]** with residual uncertainty of ~15 minutes
of staleness, not 24 hours — expect near the **top** of the bracket.

`stale_close` alone already justifies the schedule change. **No further fill-timing
simulation on this snapshot** — the bracket is the result. You cannot simulate
15:45 partial bars from daily data, and you do not need to.

### Gap-cap — dead (third tail-trim instance)

`next_open_gap2` worse than plain next_open on every window (full Sharpe 1.06 vs
1.20). **262** full-period gap-ups skipped — they were continuations.

This is the **third independent instance** of the same lesson:

1. Take-profit exits (gate target as TP)  
2. FIP as an in-book filter  
3. **Gap-up entry caps**

Any rule that trims the right tail improves cosmetic quality metrics and destroys
P&L. **Standing method rule:** momentum filters must be presumed guilty of
tail-trimming until shown otherwise.

---

## Decisions locked

1. **Biggest lever is execution scheduling**, not a strategy rewrite.  
2. ~~**Until near-close execution is live:** grade strategy promotion under
   `fill_mode=next_open`; keep close-fill as historical control.~~
   **Superseded 2026-07-18** — near-close shipped to prod (commits 736451e,
   a71dd4a; migration 023). Decision baseline is now **`fill_mode=close`**
   (≈ the live near-close path). Live fill quality is monitored via
   `fill_mode=near_close` paper trades: entry price vs the day's final close
   (available after the after-close fetch overwrites the partial bar).  
3. **After near-close ships:** grade under a close-like / near-close fill mode
   (actual live path). **← Active.**  
4. **No more sim arms** on fill timing for this snapshot.  
5. **Gap-cap:** do not ship.  
6. **Strategy work** (nasdaq_all, fip_id, sector) waits until the execution path
   is decided — those experiments must be graded under the fill mode you will trade.

---

## Ops design — implementation plan (code-checked)

Assumptions verified against current code before ship:

- Intraday pipeline already fetches/upserts the **in-progress day-t bar** all
  session (`fetch_ohlcv` end_date defaults to today). Near-close job =
  **OHLCV fetch → R:R scan** (no new snapshot synthesizer).
- One global `schedule_timezone` (default `Europe/Berlin`); stored
  `SystemSetting` values shadow code defaults — **defaults alone do not
  migrate prod**.
- `observe_reentry_gate_transitions` stamps timestamps with **no same-day
  guard** today — dual scans would accelerate fail→requalify unless fixed in
  `trade_policy`.

### Semantic guard (ship step 1 — precondition)

In `trade_policy` (not the scheduler):

> `reentry_gate_requalified_at` may only be set when `reentry_gate_failed_at`
> falls on an **earlier America/New_York trading date** than the current
> observation.

Manual mid-day scans stay allowed; same-day fail+qualify cannot unlock.
Unit test: fail 10:00 / qualify 15:35 same day → still locked; qualify next day → unlocked.

### Schedule split

| Slot (America/New_York) | Jobs |
|---|---|
| Morning (~02:00) | OHLCV backfill, benchmark, sentiment, fundamentals — **no** qualifying R:R scan |
| Near-close (~15:30 Mon–Fri) | OHLCV fetch (refresh day-t bar) → **R:R scan** (only daily qualifying observation) |
| After close (~16:30–17:00) | **Outcome eval** on its own slot (not chained to the partial-bar scan) |
| Intraday hourly | Unchanged in NY terms; last ~16:00 still mid-session under 15m feed |

- Near-close scan **1–5 only**; US-holiday no-ops are fine (stale identical data
  can’t flip gates) — comment only, no exchange calendar.
- **Do not** run morning + near-close qualifying scans; move the scan, don’t add a second.

### Behavior change to document (not an accident)

With scan at ~15:35 ET, stops closed by **earlier same-day** intraday outcome
evals can get a **same-day fail observation** — closer to the **promoted**
`gate_reset` arm (stop-day close may establish failure) than today’s
`strict_gate_reset` analogue (scan always before any eval). Stops after the
bell still wait a day. Rewrite README “Live timing matters” / post-stop sections
and a line here when shipping.

### Feed / paper honesty

- Document 15-minute delayed SIP: 15:35 scan may see ~15:20 prices; OK for 12-1.
- Paper entry price ≈ scan entry (near close) is nearly automatic; add
  **`fill_mode=near_close` era tag** so Track Record can separate morning-scan /
  near-close / future broker-routed eras.
- Morning sentiment staleness is display-only; gate is price-only (GTL parity /
  neutral-sentiment backtest). One doc line closes that.

### Stored settings migration (ship step 4)

Flip global default TZ to `America/New_York` and re-express crons in NY time.
**Also** migration (or documented Admin rewrite) of stored `schedule_*` keys so
prod does not keep 07:00 Berlin silently.

### Ship order

1. `trade_policy` distinct-day requalify guard + unit test  
2. Near-close job = existing fetch → scan; outcome eval own after-close slot  
3. Paper `fill_mode=near_close` era tag; verify entry marking  
4. Defaults + **stored settings migration** + README/research timing rewrite  

### Out of scope

Broker MOC routing, more fill-timing sim, nasdaq_all / fip / sector (grade later
under the fill mode you trade).

---

## Implementation status

| Item | Status |
|---|---|
| Research evidence | **Closed** — this doc + matrix report |
| Distinct-day gate-reset guard | **Shipped** — `trade_policy` + unit test |
| Schedule split + near-close scan→alert | **Shipped** — morning / near-close / after-close (fetch→outcome) |
| Paper era tag | **Shipped** — `fill_mode=near_close` on new paper trades |
| Settings migration + docs | **Shipped** — alembic 023 rewrites schedule_*; README updated |

### Shipped behavior change (not accidental)

Near-close scan at ~15:30 ET lets same-day fail observations after earlier
intraday stop closes — closer to promoted `gate_reset` than the old
morning-scan `strict_gate_reset` analogue. Documented in README.
