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)
|
||||
|
||||
# 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_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:
|
||||
|
||||
Reference in New Issue
Block a user