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:
2026-07-18 16:27:10 +02:00
parent 723d47338e
commit 3eb6192a1e
5 changed files with 768 additions and 26 deletions
+55 -22
View File
@@ -696,9 +696,15 @@ VOL_TARGET_CLAMP_HEADLINE = (0.5, 1.5)
VOL_TARGET_CLAMP_WIDE = (0.25, 2.0)
# 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 t1 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_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:
@@ -1801,6 +1807,7 @@ def _simulate_portfolio(
include_curve: bool = False,
include_trades: bool = False,
fill_mode: str = FILL_MODE_CLOSE,
max_entry_gap_pct: float | None = None,
vol_target: float | None = None,
vol_lookback: int = VOL_TARGET_LOOKBACK_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
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.
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``.
``stale_close`` fills at the next session's *close* (one-session-stale
signal, MOC-style) with the same stop re-anchor. ``max_entry_gap_pct``
(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
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")
if fill_mode not in 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"):
raise ValueError("corr_action must be 'skip' or 'half_size'")
if vol_target is not None and vol_target <= 0:
@@ -1886,12 +1901,12 @@ def _simulate_portfolio(
if not calendar:
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 —
# the clear-air train-window bug — for train, validation, and full-period
# books alike (including max-hold sweeps out to 90 days).
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
calendar = calendar[:cut]
if not calendar:
@@ -1905,6 +1920,7 @@ def _simulate_portfolio(
skipped_cooldown = 0
skipped_corr = 0
skipped_missing_fill = 0
skipped_gap_cap = 0
cooldown_until_index: dict[str, int] = {}
stop_refresh_attempts = 0
stop_refreshes = 0
@@ -1916,7 +1932,7 @@ def _simulate_portfolio(
atr_cache: dict[tuple[str, int], float | None] = {}
vol_scalars: list[float] = []
overnight_slippage_pct: list[float] = []
pending_next_open: list[dict] = []
pending_delayed: list[dict] = []
def _bar(sym: str, o: int):
idx = index_of.get(sym, {}).get(o)
@@ -2123,13 +2139,13 @@ def _simulate_portfolio(
reverse=True,
)
if fill_mode == FILL_MODE_NEXT_OPEN:
if fill_mode in DELAYED_FILL_MODES:
fill_candidates = sorted(
pending_next_open,
pending_delayed,
key=lambda c: c.get(ranking_key) or 0.0,
reverse=True,
)
pending_next_open = []
pending_delayed = []
else:
fill_candidates = signal_todays
@@ -2240,9 +2256,9 @@ def _simulate_portfolio(
"vol_scalar": scalar,
"corr_scale": corr_scale,
}
# next-open: the fill bar is already being traded — same-day stop applies.
# bars_held stays 0 on the fill day (matches close-fill cadence: the
# entry session does not consume a hold day); only last/high marks update.
# next_open only: fill is at the open, so the rest of the bar can stop out.
# stale_close fills at the close — same-day stop after entry does not apply.
# 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:
positions[sym]["last_close"] = fill_bar.close
positions[sym]["highest_close"] = max(entry, fill_bar.close)
@@ -2300,7 +2316,8 @@ def _simulate_portfolio(
fill_bar=None,
)
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_idx = index_of.get(sym, {}).get(signal_ord)
fill_bar = _bar(sym, o)
@@ -2311,9 +2328,17 @@ def _simulate_portfolio(
if atr is None or atr <= 0:
skipped_missing_fill += 1
continue
entry = float(fill_bar.open)
stop = entry - ATR_MULTIPLIER * atr
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)
if corr_scale is None:
skipped_corr += 1
@@ -2325,12 +2350,12 @@ def _simulate_portfolio(
entry_ord=o,
signal_close=signal_close,
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:
# Queue today's signals for the next session's open.
pending_next_open.extend(signal_todays)
if fill_mode in DELAYED_FILL_MODES:
# Queue today's signals for the next session's fill.
pending_delayed.extend(signal_todays)
curve.append((o, _marked_equity()))
@@ -2477,12 +2502,12 @@ def _simulate_portfolio(
result["corr_action"] = corr_action
result["corr_lookback"] = corr_lookback
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
if overnight_slippage_pct:
slip = sorted(overnight_slippage_pct)
mid = len(slip) // 2
result["overnight_slippage"] = {
slip_payload = {
"n": len(slip),
"mean_pct": round(sum(slip) / len(slip), 4),
"median_pct": round(
@@ -2492,6 +2517,14 @@ def _simulate_portfolio(
"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),
}
# 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:
result["equity_curve"] = curve_payload
if benchmark_payload is not None: