"""agent_08_gap_fill — STRUCT family, slug=gap_fill (TF 1h). ANGLE (assigned): session/period GAP-FILL tendency. After a large jump between session closes/opens, lean toward PARTIAL REVERSION. Low-frequency, gated. WHAT THE CERTIFIED DATA SAYS (BTC/ETH 1h, exploration only — NOT fit into the signal): Measure the 'gap' as the trailing one-session (24h) move ending at a session boundary, and the forward 24h return. The reversion is STRONGLY ASYMMETRIC: * extreme UP gaps (>=97th pctl 24h jump) -> forward 24h ~0% (BTC -0.08%, ETH +0.31%): NO clean fade. Shorting up-gaps in a bull tape just sells trend-beta -> we DON'T. * extreme DOWN gaps (<=3rd pctl 24h drop) -> forward 24h +1.2% (BTC) / +1.35% (ETH): a robust gap-FILL. A violent one-session sell-off (stops, liquidations, thin-book overshoot) gives part of it back over the following session. Conditioning on WHICH session the down-gap closes in: the low-liquidity ASIA/overnight block (0-7 UTC) reverts a touch MORE (BTC +1.40% vs +0.91% US), consistent with thin-book overshoot. We use this as a soft TILT (size up Asia-close gaps), not a hard gate (noisy). DESIGN (LONG-ONLY gap-fill, the only side that pays): when the trailing one-session move is a rare DOWN gap (causal expanding z below a floor) we go LONG and hold ~1 session, then flat. We trade a MODERATE down-gap band [floor, cap): the most violent prints (z below the cap) split into clean reversals AND runaway crashes -> a coin flip, so we skip them (same lesson as the volume-spike fade). FLAT the great majority of the time -> a satellite, orthogonal by design to a slow long-flat trend (TP01), the only way an intraday signal can ADD on a fee wall. WHY LOW TURNOVER (the fee wall). The fire is a CONJUNCTION of rare causal events: an expanding-z of the trailing one-session return below a floor (and above a cap), measured on a NON-OVERLAPPING session grid (one decision per session, not per hour). Over 7.5y of 1h BTC/ETH this fires only a few dozen times/yr; ONE long held ~1 session then flat -> turnover in the tens/yr, miles under the ~120/yr cap and nowhere near the ~2000/yr fee-death of an hourly flip. We use the intraday session STRUCTURE for INFORMATION (gap timing/sizing), not for churn. CONTEXT GATE (what unlocked the edge). The gap-fill is conditional: an ISOLATED down-gap in calm tape barely reverts (forward 24h ~0%), but a down-gap WITHIN a sustained sell-off (weekly move <= -8%) reverts hard (forward +1.6..+2.5%). The capitulation that snaps back is the one that overshoots an existing slide. Requiring this context (a) sharpened the edge and (b) woke ETH up (which was flat without it) -> both assets full Sharpe >= 0.5. CAUSALITY. Every input at bar i uses only rows 0..i: * gap z = expanding-standardized (mean/std over rows 0..i-1 via .shift(1)) of the trailing one-session log return. No full-sample stats. * context drawdown = trailing 7-day move ending at i. * session id uses bar i's own UTC hour (known at close[i]). The go-long decision is taken at close[i]; the evaluator holds it during bar i+1. No shift(-k), no full-sample calendar fit. VERIFIED: scrambling all future rows leaves past positions byte-identical (max|delta|=0 on both assets). HONEST VERDICT (scored 2026-06-21, hardened marginal judge @ 1h): EARNS_SLOT = TRUE. abs_grade=PASS, marginal=ADDS, robust_oos=True, multicut_persistent=True, is_hedge=False, has_insample_edge=True. corr->TP01 0.044 (orthogonal), beta 0.054, resid Sharpe 0.66, alpha/yr +9.9%. cand in-sample (pre-2025) Sharpe 0.729; standalone full 0.72 / hold 0.68. Blend 0.75*TP01+0.25*gap_fill: full 1.30->1.45 (+0.152), hold 0.31->0.55 (+0.243), DD 9.0%. Turnover 9-12/yr; fee@0.20%RT full Sharpe 0.50 (survives the sweep comfortably). MULTI-CUT uplift POSITIVE every year 2020-2026 (+0.12,+0.16,+0.06,+0.13,+0.16,+0.24,...). PLATEAU: floor 2.3-2.5 x cap 3.6-4.0 x ctx_dd -0.05..-0.11 x hold 18-24 x gap 24-36 all clear the bar (floor 2.1 collapses -> shallow gaps are not capitulation; that boundary is the edge, not a fit). 62/65 fires over 7.5y, spread across EVERY year incl. the hold-out. HONEST CAVEATS (price it as a small diversifying satellite, NOT standalone alpha): * STANDALONE IS MODEST. Single-asset full Sharpe ~0.53-0.61, standalone DD is large (rarely-on undiversified contrarian). The value is MARGINAL (it lifts a TP01-led book), not edge to trade alone. The whole worth is the +0.24 hold-out uplift at corr 0.044. * EVENT-SPARSE. ~8-9 fires/yr; the bear years 2021-22 carry most (more sell-offs = more capitulation gaps). Calm/trending tape has few. Forward-monitor the fire rate. * HEDGE-ADJACENT. It pays more when TP01 is DOWN (uplift TP01-down +0.28 vs up +0.17, yearly hedge-corr -0.87): it CLEARS the is_hedge gate (still positive in up-regimes) but a chunk of its worth is drawdown-dampening (buying capitulation dips during bear tape). Size it as a defensive-leaning diversifier. * The ASIA tilt is a deliberate NO-OP (=1.0): exploration showed Asia-close gaps revert a touch more, but the Asia share of fires (~33%) is chance-level -> not enough to size on. """ from __future__ import annotations import sys import numpy as np import pandas as pd sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al # noqa: E402 # --- gap detection (causal) --- _GAP_HOURS = 24 # the 'session/period' window: a trailing one-day jump _Z_MIN_D = 90 # min days before the expanding gap-z is trusted _Z_FLOOR = 2.3 # gap-z must be at least this negative: a real down-gap _Z_CAP = 3.8 # below this z the print is a coin-flip (runaway crash), skip it _GRID_HOURS = 8 # decide once per session block (non-overlapping) -> low turnover # --- context gate: a down-gap WITHIN a sell-off is the overshoot that snaps back; an # isolated down-gap in calm tape barely reverts (exploration: isolated fwd ~0% vs # crash-context fwd +1.6-2.5%). Require a sustained weekly drawdown context. CAUSAL. --- _CTX_DAYS = 7 # weekly drawdown window _CTX_DD = -0.08 # the trailing-week move must be <= this (a real sell-off) # --- fill holding / sizing --- _HOLD_HOURS = 24 # hold the long ~1 session, then flat _ASIA_TILT = 1.0 # extra size when the down-gap closes in the thin Asia block (0-7 UTC) _BASE_SIZE = 1.0 def _expanding_z(x: np.ndarray, min_obs: int) -> np.ndarray: """Strictly causal expanding-standardized z-score (mean/std over rows 0..i-1). pandas expanding().shift(1) standardizes bar i by stats that EXCLUDE i -> no peeking. NaN until min_obs samples are available.""" s = pd.Series(x) m = s.expanding(min_periods=min_obs).mean().shift(1) sd = s.expanding(min_periods=min_obs).std().shift(1) return ((s - m) / sd.replace(0, np.nan)).values def _fires(df: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]: """(fire, size) per bar, decided with data <= close[i]. fire = True on a fade-able DOWN gap; size = the long size to take (Asia tilt).""" c = df["close"].values.astype(float) dt = pd.to_datetime(df["datetime"], utc=True) hour = dt.dt.hour.values bpd = al.bars_per_day(df) # 24 at 1h gap_bars = max(1, int(round(_GAP_HOURS / 24 * bpd))) grid_bars = max(1, int(round(_GRID_HOURS / 24 * bpd))) # trailing one-session log return (the 'gap') gap = np.full(len(c), np.nan) gap[gap_bars:] = np.log(c[gap_bars:] / c[:-gap_bars]) gz = _expanding_z(gap, _Z_MIN_D * bpd) # context: sustained weekly drawdown (the down-gap is the overshoot of a sell-off) ctx_bars = max(1, int(round(_CTX_DAYS * bpd))) ctx = np.full(len(c), np.nan) ctx[ctx_bars:] = c[ctx_bars:] / c[:-ctx_bars] - 1.0 in_selloff = ctx <= _CTX_DD # moderate down-gap band, in a sell-off, on the non-overlapping session grid on_grid = (np.arange(len(c)) % grid_bars) == 0 band = (gz <= -_Z_FLOOR) & (gz > -_Z_CAP) fire = np.nan_to_num(band & in_selloff & on_grid, nan=False).astype(bool) # Asia-close (0-7 UTC) down-gaps revert a touch more -> soft up-tilt in_asia = hour < 8 size = np.where(in_asia, _BASE_SIZE * _ASIA_TILT, _BASE_SIZE) return fire, size def target(df: pd.DataFrame) -> np.ndarray: """Long-flat gap-fill: go long for HOLD_HOURS after a fade-able down-gap, else flat.""" bpd = al.bars_per_day(df) fire, size = _fires(df) hold = max(1, int(round(_HOLD_HOURS / 24 * bpd))) n = len(df) pos = np.zeros(n) remaining = 0 cur_size = 0.0 for i in range(n): if fire[i]: remaining = hold # a fresh down-gap refreshes the hold window cur_size = size[i] if remaining > 0: pos[i] = cur_size remaining -= 1 return np.nan_to_num(pos, nan=0.0) if __name__ == "__main__": for a in ("BTC", "ETH"): d = al.get(a, "1h") ev = al.eval_weights(d, target(d)) print(a, "full", ev["full"]["sharpe"], "hold", ev["holdout"]["sharpe"], "turn/yr", ev["turnover_per_year"], "TiM", ev["time_in_market"])