feat: log Phase A decisions and add execution-recovery matrix
Document Phase A (max-hold/vol/corr closed; next-open as decision baseline). Add stale_close and next_open gap-cap fill modes plus a small matrix to test whether near-close scheduling recovers overnight momentum drift.
This commit is contained in:
@@ -696,9 +696,15 @@ VOL_TARGET_CLAMP_HEADLINE = (0.5, 1.5)
|
|||||||
VOL_TARGET_CLAMP_WIDE = (0.25, 2.0)
|
VOL_TARGET_CLAMP_WIDE = (0.25, 2.0)
|
||||||
|
|
||||||
# Entry fill modes for the capital-constrained book simulator.
|
# Entry fill modes for the capital-constrained book simulator.
|
||||||
|
# close: signal and fill at the same bar's close (historical optimistic control).
|
||||||
|
# next_open: signal at t close, fill at t+1 open (honest for an overnight scanner).
|
||||||
|
# stale_close: signal at t−1 close, fill at t close (near-close / MOC-style execution
|
||||||
|
# with a one-session-stale signal — the recovery hypothesis for the next_open gap).
|
||||||
FILL_MODE_CLOSE = "close"
|
FILL_MODE_CLOSE = "close"
|
||||||
FILL_MODE_NEXT_OPEN = "next_open"
|
FILL_MODE_NEXT_OPEN = "next_open"
|
||||||
FILL_MODES = (FILL_MODE_CLOSE, FILL_MODE_NEXT_OPEN)
|
FILL_MODE_STALE_CLOSE = "stale_close"
|
||||||
|
FILL_MODES = (FILL_MODE_CLOSE, FILL_MODE_NEXT_OPEN, FILL_MODE_STALE_CLOSE)
|
||||||
|
DELAYED_FILL_MODES = (FILL_MODE_NEXT_OPEN, FILL_MODE_STALE_CLOSE)
|
||||||
|
|
||||||
|
|
||||||
def _cost_r(cand: dict) -> float:
|
def _cost_r(cand: dict) -> float:
|
||||||
@@ -1801,6 +1807,7 @@ def _simulate_portfolio(
|
|||||||
include_curve: bool = False,
|
include_curve: bool = False,
|
||||||
include_trades: bool = False,
|
include_trades: bool = False,
|
||||||
fill_mode: str = FILL_MODE_CLOSE,
|
fill_mode: str = FILL_MODE_CLOSE,
|
||||||
|
max_entry_gap_pct: float | None = None,
|
||||||
vol_target: float | None = None,
|
vol_target: float | None = None,
|
||||||
vol_lookback: int = VOL_TARGET_LOOKBACK_HEADLINE,
|
vol_lookback: int = VOL_TARGET_LOOKBACK_HEADLINE,
|
||||||
vol_clamp: tuple[float, float] = VOL_TARGET_CLAMP_HEADLINE,
|
vol_clamp: tuple[float, float] = VOL_TARGET_CLAMP_HEADLINE,
|
||||||
@@ -1832,9 +1839,13 @@ def _simulate_portfolio(
|
|||||||
``fill_mode``: ``close`` enters at the signal-bar close with the candidate's
|
``fill_mode``: ``close`` enters at the signal-bar close with the candidate's
|
||||||
stop (historical control). ``next_open`` fills at the next session's open
|
stop (historical control). ``next_open`` fills at the next session's open
|
||||||
with stop = fill − 1.5×ATR(signal bar); missing next bar skips the entry.
|
with stop = fill − 1.5×ATR(signal bar); missing next bar skips the entry.
|
||||||
Vol targeting scales ``risk_per_trade`` at entry only from equity-curve
|
``stale_close`` fills at the next session's *close* (one-session-stale
|
||||||
realized vol. Correlation caps skip or half-size candidates whose max
|
signal, MOC-style) with the same stop re-anchor. ``max_entry_gap_pct``
|
||||||
pairwise 120d return correlation with open holdings exceeds ``corr_max``.
|
(next_open only) skips entries whose open gaps up more than that fraction
|
||||||
|
vs the signal close (e.g. 0.02 = +2%). Vol targeting scales
|
||||||
|
``risk_per_trade`` at entry only from equity-curve realized vol. Correlation
|
||||||
|
caps skip or half-size candidates whose max pairwise 120d return correlation
|
||||||
|
with open holdings exceeds ``corr_max``.
|
||||||
|
|
||||||
Returns None when there is nothing to trade. ``cost_per_side`` is charged on
|
Returns None when there is nothing to trade. ``cost_per_side`` is charged on
|
||||||
entry and exit and therefore changes both cash availability and subsequent
|
entry and exit and therefore changes both cash availability and subsequent
|
||||||
@@ -1845,6 +1856,10 @@ def _simulate_portfolio(
|
|||||||
raise ValueError("cost_per_side must be between 0 (inclusive) and 1")
|
raise ValueError("cost_per_side must be between 0 (inclusive) and 1")
|
||||||
if fill_mode not in FILL_MODES:
|
if fill_mode not in FILL_MODES:
|
||||||
raise ValueError(f"fill_mode must be one of {FILL_MODES}")
|
raise ValueError(f"fill_mode must be one of {FILL_MODES}")
|
||||||
|
if max_entry_gap_pct is not None and max_entry_gap_pct < 0:
|
||||||
|
raise ValueError("max_entry_gap_pct must be non-negative when set")
|
||||||
|
if max_entry_gap_pct is not None and fill_mode != FILL_MODE_NEXT_OPEN:
|
||||||
|
raise ValueError("max_entry_gap_pct only applies to fill_mode=next_open")
|
||||||
if corr_action not in ("skip", "half_size"):
|
if corr_action not in ("skip", "half_size"):
|
||||||
raise ValueError("corr_action must be 'skip' or 'half_size'")
|
raise ValueError("corr_action must be 'skip' or 'half_size'")
|
||||||
if vol_target is not None and vol_target <= 0:
|
if vol_target is not None and vol_target <= 0:
|
||||||
@@ -1886,12 +1901,12 @@ def _simulate_portfolio(
|
|||||||
if not calendar:
|
if not calendar:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Always truncate the calendar to last_signal + hold_days (+1 for next-open
|
# Always truncate the calendar to last_signal + hold_days (+1 for delayed
|
||||||
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
|
# fill lag). Prevents trailing flat-cash after the last resolvable entry —
|
||||||
# the clear-air train-window bug — for train, validation, and full-period
|
# the clear-air train-window bug — for train, validation, and full-period
|
||||||
# books alike (including max-hold sweeps out to 90 days).
|
# books alike (including max-hold sweeps out to 90 days).
|
||||||
last_signal_ord = max(entries_by_ord)
|
last_signal_ord = max(entries_by_ord)
|
||||||
resolve_pad = hold_days + (1 if fill_mode == FILL_MODE_NEXT_OPEN else 0)
|
resolve_pad = hold_days + (1 if fill_mode in DELAYED_FILL_MODES else 0)
|
||||||
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
|
cut = bisect.bisect_left(calendar, last_signal_ord) + resolve_pad + 1
|
||||||
calendar = calendar[:cut]
|
calendar = calendar[:cut]
|
||||||
if not calendar:
|
if not calendar:
|
||||||
@@ -1905,6 +1920,7 @@ def _simulate_portfolio(
|
|||||||
skipped_cooldown = 0
|
skipped_cooldown = 0
|
||||||
skipped_corr = 0
|
skipped_corr = 0
|
||||||
skipped_missing_fill = 0
|
skipped_missing_fill = 0
|
||||||
|
skipped_gap_cap = 0
|
||||||
cooldown_until_index: dict[str, int] = {}
|
cooldown_until_index: dict[str, int] = {}
|
||||||
stop_refresh_attempts = 0
|
stop_refresh_attempts = 0
|
||||||
stop_refreshes = 0
|
stop_refreshes = 0
|
||||||
@@ -1916,7 +1932,7 @@ def _simulate_portfolio(
|
|||||||
atr_cache: dict[tuple[str, int], float | None] = {}
|
atr_cache: dict[tuple[str, int], float | None] = {}
|
||||||
vol_scalars: list[float] = []
|
vol_scalars: list[float] = []
|
||||||
overnight_slippage_pct: list[float] = []
|
overnight_slippage_pct: list[float] = []
|
||||||
pending_next_open: list[dict] = []
|
pending_delayed: list[dict] = []
|
||||||
|
|
||||||
def _bar(sym: str, o: int):
|
def _bar(sym: str, o: int):
|
||||||
idx = index_of.get(sym, {}).get(o)
|
idx = index_of.get(sym, {}).get(o)
|
||||||
@@ -2123,13 +2139,13 @@ def _simulate_portfolio(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if fill_mode == FILL_MODE_NEXT_OPEN:
|
if fill_mode in DELAYED_FILL_MODES:
|
||||||
fill_candidates = sorted(
|
fill_candidates = sorted(
|
||||||
pending_next_open,
|
pending_delayed,
|
||||||
key=lambda c: c.get(ranking_key) or 0.0,
|
key=lambda c: c.get(ranking_key) or 0.0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
pending_next_open = []
|
pending_delayed = []
|
||||||
else:
|
else:
|
||||||
fill_candidates = signal_todays
|
fill_candidates = signal_todays
|
||||||
|
|
||||||
@@ -2240,9 +2256,9 @@ def _simulate_portfolio(
|
|||||||
"vol_scalar": scalar,
|
"vol_scalar": scalar,
|
||||||
"corr_scale": corr_scale,
|
"corr_scale": corr_scale,
|
||||||
}
|
}
|
||||||
# next-open: the fill bar is already being traded — same-day stop applies.
|
# next_open only: fill is at the open, so the rest of the bar can stop out.
|
||||||
# bars_held stays 0 on the fill day (matches close-fill cadence: the
|
# stale_close fills at the close — same-day stop after entry does not apply.
|
||||||
# entry session does not consume a hold day); only last/high marks update.
|
# bars_held stays 0 on the fill day (matches close-fill cadence).
|
||||||
if fill_mode == FILL_MODE_NEXT_OPEN and fill_bar is not None:
|
if fill_mode == FILL_MODE_NEXT_OPEN and fill_bar is not None:
|
||||||
positions[sym]["last_close"] = fill_bar.close
|
positions[sym]["last_close"] = fill_bar.close
|
||||||
positions[sym]["highest_close"] = max(entry, fill_bar.close)
|
positions[sym]["highest_close"] = max(entry, fill_bar.close)
|
||||||
@@ -2300,7 +2316,8 @@ def _simulate_portfolio(
|
|||||||
fill_bar=None,
|
fill_bar=None,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# next_open: c is a prior-day signal; fill at today's open.
|
# Delayed fill: prior-day signal → today's open (next_open) or close
|
||||||
|
# (stale_close). Stop always re-anchored to fill − 1.5×ATR(signal).
|
||||||
signal_ord = date.fromisoformat(str(c["date"])).toordinal()
|
signal_ord = date.fromisoformat(str(c["date"])).toordinal()
|
||||||
signal_idx = index_of.get(sym, {}).get(signal_ord)
|
signal_idx = index_of.get(sym, {}).get(signal_ord)
|
||||||
fill_bar = _bar(sym, o)
|
fill_bar = _bar(sym, o)
|
||||||
@@ -2311,9 +2328,17 @@ def _simulate_portfolio(
|
|||||||
if atr is None or atr <= 0:
|
if atr is None or atr <= 0:
|
||||||
skipped_missing_fill += 1
|
skipped_missing_fill += 1
|
||||||
continue
|
continue
|
||||||
entry = float(fill_bar.open)
|
|
||||||
stop = entry - ATR_MULTIPLIER * atr
|
|
||||||
signal_close = float(prices[sym][4][signal_idx])
|
signal_close = float(prices[sym][4][signal_idx])
|
||||||
|
if fill_mode == FILL_MODE_NEXT_OPEN:
|
||||||
|
entry = float(fill_bar.open)
|
||||||
|
if max_entry_gap_pct is not None and signal_close > 0:
|
||||||
|
gap = entry / signal_close - 1.0
|
||||||
|
if gap > float(max_entry_gap_pct):
|
||||||
|
skipped_gap_cap += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
entry = float(fill_bar.close)
|
||||||
|
stop = entry - ATR_MULTIPLIER * atr
|
||||||
corr_scale = _corr_scale_for(sym, signal_idx)
|
corr_scale = _corr_scale_for(sym, signal_idx)
|
||||||
if corr_scale is None:
|
if corr_scale is None:
|
||||||
skipped_corr += 1
|
skipped_corr += 1
|
||||||
@@ -2325,12 +2350,12 @@ def _simulate_portfolio(
|
|||||||
entry_ord=o,
|
entry_ord=o,
|
||||||
signal_close=signal_close,
|
signal_close=signal_close,
|
||||||
corr_scale=corr_scale,
|
corr_scale=corr_scale,
|
||||||
fill_bar=fill_bar,
|
fill_bar=fill_bar if fill_mode == FILL_MODE_NEXT_OPEN else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if fill_mode == FILL_MODE_NEXT_OPEN:
|
if fill_mode in DELAYED_FILL_MODES:
|
||||||
# Queue today's signals for the next session's open.
|
# Queue today's signals for the next session's fill.
|
||||||
pending_next_open.extend(signal_todays)
|
pending_delayed.extend(signal_todays)
|
||||||
|
|
||||||
curve.append((o, _marked_equity()))
|
curve.append((o, _marked_equity()))
|
||||||
|
|
||||||
@@ -2477,12 +2502,12 @@ def _simulate_portfolio(
|
|||||||
result["corr_action"] = corr_action
|
result["corr_action"] = corr_action
|
||||||
result["corr_lookback"] = corr_lookback
|
result["corr_lookback"] = corr_lookback
|
||||||
result["skipped_corr"] = skipped_corr
|
result["skipped_corr"] = skipped_corr
|
||||||
if fill_mode == FILL_MODE_NEXT_OPEN:
|
if fill_mode in DELAYED_FILL_MODES:
|
||||||
result["skipped_missing_fill"] = skipped_missing_fill
|
result["skipped_missing_fill"] = skipped_missing_fill
|
||||||
if overnight_slippage_pct:
|
if overnight_slippage_pct:
|
||||||
slip = sorted(overnight_slippage_pct)
|
slip = sorted(overnight_slippage_pct)
|
||||||
mid = len(slip) // 2
|
mid = len(slip) // 2
|
||||||
result["overnight_slippage"] = {
|
slip_payload = {
|
||||||
"n": len(slip),
|
"n": len(slip),
|
||||||
"mean_pct": round(sum(slip) / len(slip), 4),
|
"mean_pct": round(sum(slip) / len(slip), 4),
|
||||||
"median_pct": round(
|
"median_pct": round(
|
||||||
@@ -2492,6 +2517,14 @@ def _simulate_portfolio(
|
|||||||
"p05_pct": round(slip[max(0, int(0.05 * (len(slip) - 1)))], 4),
|
"p05_pct": round(slip[max(0, int(0.05 * (len(slip) - 1)))], 4),
|
||||||
"p95_pct": round(slip[min(len(slip) - 1, int(0.95 * (len(slip) - 1)))], 4),
|
"p95_pct": round(slip[min(len(slip) - 1, int(0.95 * (len(slip) - 1)))], 4),
|
||||||
}
|
}
|
||||||
|
# next_open: true overnight gap; stale_close: one full session of drift.
|
||||||
|
if fill_mode == FILL_MODE_NEXT_OPEN:
|
||||||
|
result["overnight_slippage"] = slip_payload
|
||||||
|
else:
|
||||||
|
result["signal_to_fill_drift"] = slip_payload
|
||||||
|
if max_entry_gap_pct is not None:
|
||||||
|
result["max_entry_gap_pct"] = max_entry_gap_pct
|
||||||
|
result["skipped_gap_cap"] = skipped_gap_cap
|
||||||
if curve_payload is not None:
|
if curve_payload is not None:
|
||||||
result["equity_curve"] = curve_payload
|
result["equity_curve"] = curve_payload
|
||||||
if benchmark_payload is not None:
|
if benchmark_payload is not None:
|
||||||
|
|||||||
+22
-4
@@ -105,18 +105,36 @@ and it would also sever the last dependency the *gate* has on the weak S/R detec
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Open leads
|
## 4. Phase A matrix (2026-07-18) — closed
|
||||||
|
|
||||||
|
Full write-up: **[phase-a-matrix.md](phase-a-matrix.md)** ·
|
||||||
|
`reports/research-matrix-phase-a.json`.
|
||||||
|
|
||||||
|
| Arm | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Max-hold {45,60,90} | **Note and move on** — validation glitter, train collapse (regime interaction) |
|
||||||
|
| Equity-curve vol targeting | **Reject as edge** on this sample; park vt25 as optional DD insurance only |
|
||||||
|
| Correlation caps | **Reject**; sector caps stay Phase B with reduced expectations |
|
||||||
|
| Next-open fill | **Discovery, not reject** — honest deployable ~Sharpe 1.2 / CAGR 30%. Decision baseline for future promotion = `next_open` |
|
||||||
|
| `fip_id` re-derive | **Validated** (IC −0.045, t = −2.92) |
|
||||||
|
|
||||||
|
**Highest-leverage open work:** near-close execution recovery (scheduling, not a new signal). Simulator: `scripts/run_execution_recovery_matrix.py` (`stale_close` + gap-cap).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open leads
|
||||||
|
|
||||||
| Lead | Why it's interesting | Blocker |
|
| Lead | Why it's interesting | Blocker |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **`fip_id`** (information discreteness over the 12-1 window) | **Strongest cross-sectional signal measured on this universe** — IC −0.045, t = −2.91, correct sign | Doesn't improve *this* book (the momentum gate already captures it in-sample). Revisit when the universe broadens |
|
| **Near-close / MOC execution** | Recovers the overnight momentum drift a 07:00-Berlin scanner leaves on the table (~0.5 Sharpe / ~18pp CAGR vs close-fill) | Prove with `stale_close` arm; then schedule change |
|
||||||
|
| **`fip_id`** (information discreteness over the 12-1 window) | **Strongest cross-sectional signal measured on this universe** — IC −0.045, t = −2.91, correct sign; re-derived fingerprint matched Phase A | Doesn't improve *this* book. Revisit when the universe broadens |
|
||||||
| **Broader universe** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Also where `fip_id` could become tradeable |
|
| **Broader universe** (`nasdaq_all`) | Strengthens every week's cross-section and the IC t-stat | Also where `fip_id` could become tradeable |
|
||||||
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time |
|
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time |
|
||||||
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Method rules learned the hard way
|
## 6. Method rules learned the hard way
|
||||||
|
|
||||||
1. **Nested lookback windows are NOT out-of-sample.** The clear-air result (#2) was
|
1. **Nested lookback windows are NOT out-of-sample.** The clear-air result (#2) was
|
||||||
clean, large, and consistent across five nested windows — and still died on a
|
clean, large, and consistent across five nested windows — and still died on a
|
||||||
@@ -133,7 +151,7 @@ and it would also sever the last dependency the *gate* has on the weak S/R detec
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Why we stay with the current strategy
|
## 7. Why we stay with the current strategy
|
||||||
|
|
||||||
Everything we've tried to add has either failed the backtest, failed
|
Everything we've tried to add has either failed the backtest, failed
|
||||||
out-of-sample, or turned out to be measuring something other than what it claimed.
|
out-of-sample, or turned out to be measuring something other than what it claimed.
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Phase A research matrix (2026-07-18) — results and decisions
|
||||||
|
|
||||||
|
Report: `reports/research-matrix-phase-a.json` / `.md`
|
||||||
|
Branch: `research/portfolio-vol-and-followups`
|
||||||
|
Validation split: entries ≥ **2024-07-01** (called *validation*, not holdout — this window has been opened before).
|
||||||
|
Cadence: daily, production gate/rank/trail + gate-reset re-entry.
|
||||||
|
Pre-registered N for DSR: **20**.
|
||||||
|
|
||||||
|
## Pre-registered promotion rule (unchanged after run start)
|
||||||
|
|
||||||
|
Promote only if **all** of:
|
||||||
|
|
||||||
|
1. Validation Sharpe ≥ control
|
||||||
|
2. Validation max DD not worse by more than **2pp**
|
||||||
|
3. Train Sharpe not worse (both-windows consistency)
|
||||||
|
|
||||||
|
Always report whether validation ΔSharpe exceeds **1 × SE** (expect most will not).
|
||||||
|
|
||||||
|
Mechanics guards confirmed before reading results: calendar truncation asserted on every arm; next-open re-anchors stop to fill − 1.5×ATR(signal); vol scalars apply at entry only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control baseline
|
||||||
|
|
||||||
|
| Window | Sharpe | SE | CAGR | MaxDD | Calmar | Trades |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Train | 1.75 | 0.68 | 49.8% | 17.9% | 2.78 | 240 |
|
||||||
|
| **Validation** | **1.68** | **0.72** | **41.6%** | **20.9%** | **1.99** | **239** |
|
||||||
|
| Full (close-fill) | 1.77 | 0.50 | 48.3% | 21.6% | 2.23 | 472 |
|
||||||
|
|
||||||
|
Validation SE ≈ 0.72 — almost no arm clears a 1-SE delta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-arm decisions
|
||||||
|
|
||||||
|
### A2 — Max hold {30, 45, 60, 90} — **note and move on**
|
||||||
|
|
||||||
|
| Hold | Val Sharpe | Val DD | Train Sharpe | Trades train |
|
||||||
|
|---:|---:|---:|---:|---:|
|
||||||
|
| 30 | 1.68 | 20.9 | 1.75 | 240 |
|
||||||
|
| 45 | **2.07** | 19.3 | **1.43** | 218 |
|
||||||
|
| 60 | **2.12** | 19.3 | **1.11** | 186 |
|
||||||
|
| 90 | 2.04 | 23.1 | 1.30 | 179 |
|
||||||
|
|
||||||
|
Validation-only would have “found” +0.4 Sharpe. Train collapses: longer holds leave stale names blocking slots (240 → 186 trades at hold-60). This is a **regime interaction** (trend validation vs chop train), not a free knob. A regime-conditional hold is a large research program; prior regime-overlay work already argues against that path.
|
||||||
|
|
||||||
|
**Decision: keep max hold 30. Do not ship longer static holds.**
|
||||||
|
|
||||||
|
### A3 — Equity-curve vol targeting — **reject as edge; park as optional insurance**
|
||||||
|
|
||||||
|
Scalars averaged 0.77–1.07 as designed (grid straddled historical book vol ~22–25%). Lower targets de-levered; **vt25** was nearly neutral (val Sharpe 1.63 vs 1.68). Wide clamp ≈ headline clamp. Lookback sensitivity did not unlock a win.
|
||||||
|
|
||||||
|
This sample has **no major vol-regime shift**, so the run **rejects vol targeting as an edge on this data** — it does **not** reject crash-insurance value in a future high-vol regime. The ~0.02 Sharpe cost at vt25 is a nearly free insurance policy if drawdown tolerance ever tightens.
|
||||||
|
|
||||||
|
**Decision: do not ship. Settles “Phase 2 = vol-scaled momentum” as an edge plan on this snapshot. Park vt25 as optional risk preference only.**
|
||||||
|
|
||||||
|
### A5 — Correlation caps — **reject; sector caps stay Phase B with reduced expectations**
|
||||||
|
|
||||||
|
Best near-miss: **0.6 skip** — val Sharpe 1.70, val DD **17.0%** (tempting), but train Sharpe 1.61 < 1.75 and full-period Sharpe **1.59 vs 1.77** (the cap deletes real momentum concentration profit). Half-size variants were worse.
|
||||||
|
|
||||||
|
**Decision: no pure corr cap. Sector caps remain Phase B with reduced expectations.**
|
||||||
|
|
||||||
|
### A4 — Next-open fill — **not a reject; the discovery**
|
||||||
|
|
||||||
|
| | Close control | Next-open |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Full Sharpe | 1.77 | **1.20** |
|
||||||
|
| Full CAGR | 48.3% | **30.0%** |
|
||||||
|
| Val Sharpe | 1.68 | 1.44 |
|
||||||
|
| Val DD | 20.9% | **28.2%** |
|
||||||
|
|
||||||
|
Overnight gap on validation entries: mean **−0.52%**, median −0.18%, p05 −4.5%, p95 +2.1% (n=243).
|
||||||
|
|
||||||
|
This is not “slippage noise.” It is largely the **overnight momentum drift** that close-fill earns and a 07:00-Berlin scanner (signal yesterday’s close → fill tomorrow’s open) **structurally cannot**. Honest deployable number under that schedule is ~Sharpe 1.2 / CAGR 30%, not 1.77 / 48%.
|
||||||
|
|
||||||
|
**Decision baseline going forward:** grade **promotion** under `fill_mode=next_open`; keep close-fill as the historical control for comparability with prior reports.
|
||||||
|
|
||||||
|
**Highest-leverage follow-up (not a strategy change):** near-close / MOC-style execution (~15:45 ET) so live fills sit near the close the signal is built on. Simulator proof arm: `stale_close` (signal t−1 close, fill t close). Secondary: next-open **gap-cap** (skip open > +2% vs signal close) — measure, don’t assume.
|
||||||
|
|
||||||
|
### `fip_id` re-derivation — **validated**
|
||||||
|
|
||||||
|
Weekly IC fingerprint on this snapshot: **mean IC −0.045, t = −2.92**, reliable (35 weeks). Matches the July record. Safe to reuse when the universe broadens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promotion table (rule as written)
|
||||||
|
|
||||||
|
| Outcome | Arms |
|
||||||
|
|---|---|
|
||||||
|
| Promote | only `a2_hold_30` (identity with control) |
|
||||||
|
| Reject | every other arm |
|
||||||
|
|
||||||
|
No arm cleared ΔSharpe > 1 SE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What not to do next
|
||||||
|
|
||||||
|
- Re-litigate rejected-table items, min_rr, GTL
|
||||||
|
- Regime-conditional max-hold as a “small” experiment
|
||||||
|
- Treat validation-only max-hold glitter as a free CAGR lift
|
||||||
|
- Ship vol targeting as edge without a vol-regime sample
|
||||||
|
|
||||||
|
## What to do next
|
||||||
|
|
||||||
|
1. **Execution recovery matrix** — `stale_close` vs close vs next_open; optional gap-cap under next_open (`scripts/run_execution_recovery_matrix.py`).
|
||||||
|
2. If `stale_close` ≈ close control: schedule scan near the US close (not a signal rewrite).
|
||||||
|
3. Until near-close execution ships live: **decision baseline = next_open**.
|
||||||
|
4. Phase B data work only when wanted: `nasdaq_all` + `fip_id`, earnings calendar, sector residual/caps.
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""Execution-recovery matrix: is the close→next_open gap recoverable by scheduling?
|
||||||
|
|
||||||
|
Hypothesis (from Phase A A4)
|
||||||
|
----------------------------
|
||||||
|
The 1.77 → 1.20 full-period Sharpe gap under next_open fill is mostly overnight
|
||||||
|
momentum drift that a 07:00-Berlin scanner cannot earn. Near-close / MOC-style
|
||||||
|
execution (scan ~15:45 ET, fill at/near that close) should recover it.
|
||||||
|
|
||||||
|
Pre-registered arms (N for DSR = 4)
|
||||||
|
----------------------------------
|
||||||
|
1. ``close_control`` — historical optimistic control (signal = fill at same close).
|
||||||
|
2. ``next_open`` — honest overnight scanner (decision baseline for future promotion).
|
||||||
|
3. ``stale_close`` — signal at t−1 close, fill at t close (one-session-stale MOC proxy).
|
||||||
|
Expectation: ≈ close_control; if so, the gap is scheduling, not physics.
|
||||||
|
4. ``next_open_gap2`` — next_open but skip entries that open > +2% above signal close.
|
||||||
|
Measure whether large gap-ups are toxic or the best continuations.
|
||||||
|
|
||||||
|
Promotion / read rule (pre-registered)
|
||||||
|
--------------------------------------
|
||||||
|
- Decision baseline for *future* strategy work: ``next_open``.
|
||||||
|
- Recovery success for ``stale_close``: validation Sharpe within 0.5×SE of
|
||||||
|
``close_control`` **and** validation Sharpe ≥ ``next_open``; train Sharpe not
|
||||||
|
worse than close_control by more than 0.5×SE. State 1-SE distinguishability.
|
||||||
|
- ``next_open_gap2`` is measurement-only vs ``next_open`` (no auto-promote to live).
|
||||||
|
|
||||||
|
Reuses the same daily candidate cache as the Phase A matrix when the cache key
|
||||||
|
matches.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
python scripts/run_execution_recovery_matrix.py backtest_snapshots/prod.sqlite \\
|
||||||
|
--workers 7 --allow-spawn \\
|
||||||
|
--candidate-cache reports/.cache/research-cands.pkl \\
|
||||||
|
--out reports/execution-recovery-matrix.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
# Must match Phase A cache when reusing research-cands.pkl
|
||||||
|
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||||
|
|
||||||
|
PRE_REGISTERED_ARMS: tuple[dict[str, Any], ...] = (
|
||||||
|
{
|
||||||
|
"id": "close_control",
|
||||||
|
"label": "Close fill (historical control)",
|
||||||
|
"fill_mode": "close",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "next_open",
|
||||||
|
"label": "Next-open fill (decision baseline)",
|
||||||
|
"fill_mode": "next_open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stale_close",
|
||||||
|
"label": "Stale-signal close fill (MOC proxy: signal t-1, fill t close)",
|
||||||
|
"fill_mode": "stale_close",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "next_open_gap2",
|
||||||
|
"label": "Next-open + skip gap-up > 2%",
|
||||||
|
"fill_mode": "next_open",
|
||||||
|
"max_entry_gap_pct": 0.02,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
N_TRIALS = len(PRE_REGISTERED_ARMS)
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_url(path: Path) -> str:
|
||||||
|
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("snapshot")
|
||||||
|
p.add_argument("--workers", type=int, default=6)
|
||||||
|
p.add_argument("--allow-spawn", action="store_true")
|
||||||
|
p.add_argument("--out", default=None)
|
||||||
|
p.add_argument("--candidate-cache", default=None)
|
||||||
|
p.add_argument("--validation-split", default="2024-07-01")
|
||||||
|
p.add_argument("--cadence", choices=("daily", "weekly"), default="daily")
|
||||||
|
p.add_argument("--quiet", action="store_true")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _period_percentiles(
|
||||||
|
observations: list[dict], value_key: str
|
||||||
|
) -> dict[tuple[str, str], float]:
|
||||||
|
by_period: dict[tuple, list[dict]] = {}
|
||||||
|
for row in observations:
|
||||||
|
if row.get(value_key) is None:
|
||||||
|
continue
|
||||||
|
period = tuple(row["ranking_period"])
|
||||||
|
by_period.setdefault(period, []).append(row)
|
||||||
|
result: dict[tuple[str, str], float] = {}
|
||||||
|
for group in by_period.values():
|
||||||
|
ordered = sorted(
|
||||||
|
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
|
||||||
|
)
|
||||||
|
denominator = len(ordered) - 1
|
||||||
|
for rank, row in enumerate(ordered):
|
||||||
|
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||||
|
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _live_universe_rank_map(
|
||||||
|
observations: list[dict],
|
||||||
|
benchmark_closes: dict[date, float],
|
||||||
|
momentum_weight: float,
|
||||||
|
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||||
|
raw_pct = _period_percentiles(observations, "momentum")
|
||||||
|
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||||
|
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||||
|
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||||
|
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||||
|
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||||
|
for row in observations:
|
||||||
|
identity = (str(row["symbol"]), str(row["date"]))
|
||||||
|
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||||
|
momentum_pct = (
|
||||||
|
residual_pct.get(identity)
|
||||||
|
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||||
|
else raw_pct.get(identity)
|
||||||
|
)
|
||||||
|
volatility_pct = vol_pct.get(identity)
|
||||||
|
strategy_rank = (
|
||||||
|
round(
|
||||||
|
momentum_pct * momentum_weight
|
||||||
|
+ volatility_pct * (1.0 - momentum_weight),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if momentum_pct is not None and volatility_pct is not None
|
||||||
|
else momentum_pct
|
||||||
|
)
|
||||||
|
ranks[identity] = {
|
||||||
|
"momentum_percentile": momentum_pct,
|
||||||
|
"volatility_percentile": volatility_pct,
|
||||||
|
"strategy_rank": strategy_rank,
|
||||||
|
}
|
||||||
|
return ranks
|
||||||
|
|
||||||
|
|
||||||
|
def _window(arm: dict, name: str) -> dict | None:
|
||||||
|
for row in arm.get("windows") or []:
|
||||||
|
if row.get("window") == name:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _grade_stale_close(close_arm: dict, next_arm: dict, stale_arm: dict) -> dict:
|
||||||
|
c_val = _window(close_arm, "validation") or {}
|
||||||
|
n_val = _window(next_arm, "validation") or {}
|
||||||
|
s_val = _window(stale_arm, "validation") or {}
|
||||||
|
c_tr = _window(close_arm, "train") or {}
|
||||||
|
s_tr = _window(stale_arm, "train") or {}
|
||||||
|
keys = ("sharpe", "sharpe_se")
|
||||||
|
if any(c_val.get(k) is None for k in keys) or s_val.get("sharpe") is None:
|
||||||
|
return {"recover": False, "reason": "missing Sharpe rows"}
|
||||||
|
se = float(c_val.get("sharpe_se") or s_val.get("sharpe_se") or 0.0)
|
||||||
|
half_se = 0.5 * se if se > 0 else 0.0
|
||||||
|
cs, ss, ns = float(c_val["sharpe"]), float(s_val["sharpe"]), n_val.get("sharpe")
|
||||||
|
cts, sts = c_tr.get("sharpe"), s_tr.get("sharpe")
|
||||||
|
near_close = abs(ss - cs) <= half_se if half_se > 0 else abs(ss - cs) < 0.05
|
||||||
|
beats_next = ns is None or ss >= float(ns)
|
||||||
|
train_ok = (
|
||||||
|
cts is None
|
||||||
|
or sts is None
|
||||||
|
or float(sts) >= float(cts) - half_se
|
||||||
|
)
|
||||||
|
recover = near_close and beats_next and train_ok
|
||||||
|
return {
|
||||||
|
"recover": recover,
|
||||||
|
"near_close_control": near_close,
|
||||||
|
"beats_next_open": beats_next,
|
||||||
|
"train_ok": train_ok,
|
||||||
|
"validation_delta_vs_close": round(ss - cs, 4),
|
||||||
|
"validation_delta_vs_next_open": (
|
||||||
|
round(ss - float(ns), 4) if ns is not None else None
|
||||||
|
),
|
||||||
|
"half_se": half_se,
|
||||||
|
"reason": (
|
||||||
|
"stale_close recovers close-fill economics (within 0.5 SE) and beats next_open"
|
||||||
|
if recover
|
||||||
|
else "stale_close does not meet recovery criteria — see flags"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _markdown(report: dict) -> str:
|
||||||
|
lines = [
|
||||||
|
f"# Execution recovery matrix — {report.get('generated_at', '')}",
|
||||||
|
"",
|
||||||
|
f"Validation split: **{report.get('validation_split')}**. N for DSR: **{report.get('n_trials')}**.",
|
||||||
|
"",
|
||||||
|
"| arm | window | Sharpe | SE | CAGR | MaxDD | trades | gap/drift |",
|
||||||
|
"|---|---|---:|---:|---:|---:|---:|---|",
|
||||||
|
]
|
||||||
|
for arm in report.get("arms") or []:
|
||||||
|
for w in arm.get("windows") or []:
|
||||||
|
slip = w.get("overnight_slippage") or w.get("signal_to_fill_drift") or {}
|
||||||
|
slip_s = (
|
||||||
|
f"mean {slip.get('mean_pct')}% n={slip.get('n')}"
|
||||||
|
if slip
|
||||||
|
else "—"
|
||||||
|
)
|
||||||
|
if w.get("skipped_gap_cap") is not None:
|
||||||
|
slip_s += f"; gap_skips={w.get('skipped_gap_cap')}"
|
||||||
|
lines.append(
|
||||||
|
f"| {arm.get('id')} | {w.get('window')} | {w.get('sharpe')} | "
|
||||||
|
f"{w.get('sharpe_se')} | {w.get('cagr_pct')} | {w.get('max_drawdown_pct')} | "
|
||||||
|
f"{w.get('trades')} | {slip_s} |"
|
||||||
|
)
|
||||||
|
rec = report.get("recovery") or {}
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Recovery decision (stale_close)",
|
||||||
|
"",
|
||||||
|
f"- **recover: {rec.get('recover')}** — {rec.get('reason')}",
|
||||||
|
f"- flags: { {k: rec.get(k) for k in ('near_close_control', 'beats_next_open', 'train_ok', 'validation_delta_vs_close', 'validation_delta_vs_next_open', 'half_se')} }",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint(path: Path, report: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
path.with_suffix(".md").write_text(_markdown(report), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
snapshot = Path(args.snapshot)
|
||||||
|
if not snapshot.exists():
|
||||||
|
raise SystemExit(f"Snapshot not found: {snapshot}")
|
||||||
|
validation_split = date.fromisoformat(args.validation_split)
|
||||||
|
out_path = (
|
||||||
|
Path(args.out)
|
||||||
|
if args.out
|
||||||
|
else Path("reports")
|
||||||
|
/ f"execution-recovery-matrix-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.environ["BACKTEST_SNAPSHOT_OFFLINE"] = "1"
|
||||||
|
if args.allow_spawn:
|
||||||
|
os.environ["BACKTEST_ALLOW_SPAWN"] = "1"
|
||||||
|
|
||||||
|
from app.models.ticker import Ticker
|
||||||
|
from app.services import backtest_service as bt
|
||||||
|
from app.services.admin_service import get_activation_config
|
||||||
|
from app.services.paper_trade_service import get_exit_policy
|
||||||
|
from app.services.recommendation_service import get_recommendation_config
|
||||||
|
|
||||||
|
db_engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||||
|
Session = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
try:
|
||||||
|
async with Session() as db:
|
||||||
|
recommendation_config = await get_recommendation_config(db)
|
||||||
|
activation = await get_activation_config(db)
|
||||||
|
exit_config = await get_exit_policy(db)
|
||||||
|
benchmark_closes = await bt._load_benchmark_closes_for_backtest(
|
||||||
|
db, days=None, refresh=False
|
||||||
|
)
|
||||||
|
ticker_result = await db.execute(select(Ticker).order_by(Ticker.symbol))
|
||||||
|
symbols = [t.symbol for t in ticker_result.scalars().all()]
|
||||||
|
prices: dict[str, tuple] = {}
|
||||||
|
for index, symbol in enumerate(symbols, 1):
|
||||||
|
columns = await bt._fetch_columns(db, symbol)
|
||||||
|
if columns is not None:
|
||||||
|
prices[symbol] = columns
|
||||||
|
if not args.quiet and index % 50 == 0:
|
||||||
|
print(f"loaded prices: {index}/{len(symbols)}", flush=True)
|
||||||
|
finally:
|
||||||
|
await db_engine.dispose()
|
||||||
|
|
||||||
|
snapshot_stat = snapshot.stat()
|
||||||
|
cache_key = {
|
||||||
|
"version": CACHE_VERSION,
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"snapshot_size": snapshot_stat.st_size,
|
||||||
|
"snapshot_mtime_ns": snapshot_stat.st_mtime_ns,
|
||||||
|
"cadence": args.cadence,
|
||||||
|
"target_model": "production_gtl",
|
||||||
|
}
|
||||||
|
cache_path = Path(args.candidate_cache) if args.candidate_cache else None
|
||||||
|
qualified: list[dict] | None = None
|
||||||
|
entry_candidate_count = 0
|
||||||
|
|
||||||
|
if cache_path is not None and cache_path.exists():
|
||||||
|
with cache_path.open("rb") as handle:
|
||||||
|
cached = pickle.load(handle) # noqa: S301
|
||||||
|
if cached.get("key") == cache_key:
|
||||||
|
qualified = list(cached["qualified_candidates"])
|
||||||
|
entry_candidate_count = int(cached.get("entry_candidate_count") or 0)
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"loaded candidate cache: {cache_path}", flush=True)
|
||||||
|
|
||||||
|
if qualified is None:
|
||||||
|
workers = max(1, min(int(args.workers), max(1, multiprocessing.cpu_count() - 1)))
|
||||||
|
context = bt._mp_context() or multiprocessing.get_context("spawn")
|
||||||
|
replay_rows: list[dict] = []
|
||||||
|
with ProcessPoolExecutor(max_workers=workers, mp_context=context) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(
|
||||||
|
bt._replay_candidates_for_period,
|
||||||
|
symbol,
|
||||||
|
columns,
|
||||||
|
recommendation_config,
|
||||||
|
activation,
|
||||||
|
benchmark_closes,
|
||||||
|
date(1900, 1, 1),
|
||||||
|
args.cadence,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
): symbol
|
||||||
|
for symbol, columns in prices.items()
|
||||||
|
}
|
||||||
|
for index, future in enumerate(as_completed(futures), 1):
|
||||||
|
replay_rows.extend(future.result())
|
||||||
|
if not args.quiet and index % 25 == 0:
|
||||||
|
print(f"replay: {index}/{len(futures)}", flush=True)
|
||||||
|
setup_candidates = [row for row in replay_rows if not row.get("_rank_only")]
|
||||||
|
rank_observations = [
|
||||||
|
row for row in replay_rows if row.get("_universe_rank_observation")
|
||||||
|
]
|
||||||
|
entry_candidate_count = len(setup_candidates)
|
||||||
|
live_ranks = _live_universe_rank_map(
|
||||||
|
rank_observations,
|
||||||
|
benchmark_closes,
|
||||||
|
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||||
|
)
|
||||||
|
threshold = float(activation.get("min_momentum_percentile", 80.0))
|
||||||
|
qualified = []
|
||||||
|
for setup in setup_candidates:
|
||||||
|
if setup.get("direction") != "long":
|
||||||
|
continue
|
||||||
|
candidate = {
|
||||||
|
k: v
|
||||||
|
for k, v in setup.items()
|
||||||
|
if not k.startswith("_universe_")
|
||||||
|
}
|
||||||
|
rank = live_ranks.get((str(setup["symbol"]), str(setup["date"])))
|
||||||
|
if rank is None:
|
||||||
|
continue
|
||||||
|
candidate[bt.PRODUCTION_PERCENTILE_KEY] = rank["momentum_percentile"]
|
||||||
|
candidate[bt.VOL_PERCENTILE_KEY] = rank["volatility_percentile"]
|
||||||
|
candidate[bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] = rank["strategy_rank"]
|
||||||
|
candidate["qualified"] = bt._momentum_qualifies(candidate, threshold)
|
||||||
|
if candidate["qualified"]:
|
||||||
|
qualified.append(candidate)
|
||||||
|
if cache_path is not None:
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with cache_path.open("wb") as handle:
|
||||||
|
pickle.dump(
|
||||||
|
{
|
||||||
|
"key": cache_key,
|
||||||
|
"entry_candidate_count": entry_candidate_count,
|
||||||
|
"qualified_candidates": qualified,
|
||||||
|
},
|
||||||
|
handle,
|
||||||
|
protocol=pickle.HIGHEST_PROTOCOL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not qualified:
|
||||||
|
raise SystemExit("No qualified candidates")
|
||||||
|
|
||||||
|
strategy = next(s for s in bt.PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
|
||||||
|
entry_config = bt._entry_variant_config(str(strategy["entry_variant"]))
|
||||||
|
assert entry_config is not None
|
||||||
|
ranking_key = str(entry_config.get("ranking_key") or entry_config["percentile_key"])
|
||||||
|
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
|
||||||
|
str(exit_config.get("mode", "atr_trailing")), "atr_trail3"
|
||||||
|
)
|
||||||
|
hold_days = int(exit_config.get("hold_days", 30))
|
||||||
|
trail_multiplier = float(exit_config.get("atr_multiplier", bt.ATR_TRAIL_MULTIPLIER))
|
||||||
|
risk_per_trade = float(entry_config["risk_per_trade"])
|
||||||
|
max_positions = int(entry_config["max_positions"])
|
||||||
|
post_stop = bt._make_gate_reset_reentry_fn(
|
||||||
|
qualified, prices, cadence=args.cadence, ranking_key=ranking_key
|
||||||
|
)
|
||||||
|
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"snapshot": str(snapshot.resolve()),
|
||||||
|
"validation_split": validation_split.isoformat(),
|
||||||
|
"n_trials": N_TRIALS,
|
||||||
|
"hypothesis": (
|
||||||
|
"stale_close (signal t-1, fill t close) recovers close-fill economics; "
|
||||||
|
"the next_open haircut is scheduling, not lost edge"
|
||||||
|
),
|
||||||
|
"decision_baseline": "next_open",
|
||||||
|
"qualified_longs": len(qualified),
|
||||||
|
"arms": [],
|
||||||
|
"recovery": {},
|
||||||
|
}
|
||||||
|
_checkpoint(out_path, report)
|
||||||
|
|
||||||
|
by_id: dict[str, dict] = {}
|
||||||
|
for arm in PRE_REGISTERED_ARMS:
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"running {arm['id']} ...", flush=True)
|
||||||
|
windows = []
|
||||||
|
for window_name, start, end in (
|
||||||
|
("train", None, validation_split),
|
||||||
|
("validation", validation_split, None),
|
||||||
|
("full", None, None),
|
||||||
|
):
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
qualified,
|
||||||
|
prices,
|
||||||
|
benchmark_closes,
|
||||||
|
exit_policy,
|
||||||
|
hold_days,
|
||||||
|
ranking_key=ranking_key,
|
||||||
|
max_positions=max_positions,
|
||||||
|
risk_per_trade=risk_per_trade,
|
||||||
|
atr_trail_multiplier=trail_multiplier,
|
||||||
|
post_stop_reentry_fn=post_stop,
|
||||||
|
start_date=start,
|
||||||
|
end_date=end,
|
||||||
|
fill_mode=str(arm["fill_mode"]),
|
||||||
|
max_entry_gap_pct=arm.get("max_entry_gap_pct"),
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
if sim is None:
|
||||||
|
windows.append({"window": window_name, "error": "no_trades"})
|
||||||
|
continue
|
||||||
|
dsr = bt.deflated_sharpe_ratio(
|
||||||
|
sim.get("sharpe"),
|
||||||
|
sim.get("sharpe_se"),
|
||||||
|
N_TRIALS,
|
||||||
|
n_returns=sim.get("n_returns"),
|
||||||
|
return_skew=sim.get("return_skew"),
|
||||||
|
return_kurtosis=sim.get("return_kurtosis"),
|
||||||
|
)
|
||||||
|
sim.pop("trade_details", None)
|
||||||
|
sim.pop("equity_curve", None)
|
||||||
|
sim.pop("benchmark_curve", None)
|
||||||
|
sim.pop("reentry_events", None)
|
||||||
|
windows.append({"window": window_name, "dsr": dsr, **sim})
|
||||||
|
row = {"id": arm["id"], "label": arm["label"], "config": {
|
||||||
|
k: arm[k] for k in arm if k not in {"id", "label"}
|
||||||
|
}, "windows": windows}
|
||||||
|
by_id[arm["id"]] = row
|
||||||
|
report["arms"].append(row)
|
||||||
|
_checkpoint(out_path, report)
|
||||||
|
if not args.quiet:
|
||||||
|
val = _window(row, "validation") or {}
|
||||||
|
print(
|
||||||
|
f" {arm['id']}: val Sharpe={val.get('sharpe')} "
|
||||||
|
f"DD={val.get('max_drawdown_pct')} trades={val.get('trades')}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(k in by_id for k in ("close_control", "next_open", "stale_close")):
|
||||||
|
report["recovery"] = _grade_stale_close(
|
||||||
|
by_id["close_control"], by_id["next_open"], by_id["stale_close"]
|
||||||
|
)
|
||||||
|
_checkpoint(out_path, report)
|
||||||
|
if not args.quiet:
|
||||||
|
print(f"wrote {out_path}", flush=True)
|
||||||
|
print(f"recovery: {report.get('recovery')}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -1066,6 +1066,93 @@ class TestSimulatePortfolio:
|
|||||||
# end should be near entry + hold (trading days ≈ calendar for synthetic series)
|
# end should be near entry + hold (trading days ≈ calendar for synthetic series)
|
||||||
assert (end - entry).days <= 10
|
assert (end - entry).days <= 10
|
||||||
|
|
||||||
|
def test_stale_close_fills_next_session_close_with_reanchored_stop(self):
|
||||||
|
n = 40
|
||||||
|
closes = [100.0 + 0.1 * i for i in range(n)]
|
||||||
|
opens = list(closes)
|
||||||
|
highs = [c + 2.0 for c in closes]
|
||||||
|
lows = [c - 2.0 for c in closes]
|
||||||
|
ords = list(range(self.ORD, self.ORD + n))
|
||||||
|
signal_i = n - 2
|
||||||
|
fill_i = n - 1
|
||||||
|
closes[fill_i] = 110.0
|
||||||
|
opens[fill_i] = 105.0
|
||||||
|
highs[fill_i] = 111.0
|
||||||
|
lows[fill_i] = 104.0
|
||||||
|
prices = {
|
||||||
|
"AAA": (ords, opens, highs, lows, closes, [1_000_000] * n)
|
||||||
|
}
|
||||||
|
cand = _sim_cand(
|
||||||
|
"AAA",
|
||||||
|
self.ORD + signal_i,
|
||||||
|
entry=closes[signal_i],
|
||||||
|
stop=closes[signal_i] - 5.0,
|
||||||
|
target=200.0,
|
||||||
|
)
|
||||||
|
sim = bt._simulate_portfolio(
|
||||||
|
[cand],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
5,
|
||||||
|
fill_mode=bt.FILL_MODE_STALE_CLOSE,
|
||||||
|
cost_per_side=0.0,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
assert sim is not None
|
||||||
|
assert sim["fill_mode"] == "stale_close"
|
||||||
|
assert sim["trades"] == 1
|
||||||
|
trade = sim["trade_details"][0]
|
||||||
|
assert trade["entry"] == pytest.approx(110.0)
|
||||||
|
# ATR ~4 on this synthetic series → stop = 110 − 1.5×4 = 104
|
||||||
|
assert trade["initial_stop"] == pytest.approx(110.0 - 1.5 * 4.0, abs=0.5)
|
||||||
|
assert "signal_to_fill_drift" in sim
|
||||||
|
|
||||||
|
def test_next_open_gap_cap_skips_large_gap_ups(self):
|
||||||
|
n = 40
|
||||||
|
closes = [100.0] * n
|
||||||
|
opens = [100.0] * n
|
||||||
|
highs = [102.0] * n
|
||||||
|
lows = [98.0] * n
|
||||||
|
ords = list(range(self.ORD, self.ORD + n))
|
||||||
|
signal_i = n - 2
|
||||||
|
fill_i = n - 1
|
||||||
|
opens[fill_i] = 110.0 # +10% gap vs signal close 100
|
||||||
|
highs[fill_i] = 111.0
|
||||||
|
lows[fill_i] = 109.0
|
||||||
|
closes[fill_i] = 110.5
|
||||||
|
prices = {
|
||||||
|
"AAA": (ords, opens, highs, lows, closes, [1_000_000] * n)
|
||||||
|
}
|
||||||
|
cand = _sim_cand(
|
||||||
|
"AAA", self.ORD + signal_i, entry=100.0, stop=95.0, target=130.0
|
||||||
|
)
|
||||||
|
blocked = bt._simulate_portfolio(
|
||||||
|
[cand],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
5,
|
||||||
|
fill_mode=bt.FILL_MODE_NEXT_OPEN,
|
||||||
|
max_entry_gap_pct=0.02,
|
||||||
|
cost_per_side=0.0,
|
||||||
|
)
|
||||||
|
allowed = bt._simulate_portfolio(
|
||||||
|
[cand],
|
||||||
|
prices,
|
||||||
|
None,
|
||||||
|
"hold",
|
||||||
|
5,
|
||||||
|
fill_mode=bt.FILL_MODE_NEXT_OPEN,
|
||||||
|
cost_per_side=0.0,
|
||||||
|
include_trades=True,
|
||||||
|
)
|
||||||
|
assert blocked is None or blocked.get("trades", 0) == 0
|
||||||
|
if blocked is not None:
|
||||||
|
assert blocked.get("skipped_gap_cap", 0) >= 1
|
||||||
|
assert allowed is not None and allowed["trades"] == 1
|
||||||
|
assert allowed["trade_details"][0]["entry"] == pytest.approx(110.0)
|
||||||
|
|
||||||
|
|
||||||
def test_fip_id_sign_convention_steady_climber_vs_jump():
|
def test_fip_id_sign_convention_steady_climber_vs_jump():
|
||||||
# Steady climber: many up days, continuous path → lower (more negative) ID.
|
# Steady climber: many up days, continuous path → lower (more negative) ID.
|
||||||
|
|||||||
Reference in New Issue
Block a user