diff --git a/.gitignore b/.gitignore index 38fd015..d6e1726 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ scripts/research/xsec/runs/out/ # blind-signal derived data (regenerable via make_blind.py) data/blind/ scripts/research/blind/leaderboard.json + +# forward-monitor runtime state (regenerable, forward-only) +data/paper_prevday/ diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index f109549..4bceeb0 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -9,6 +9,7 @@ mkdir -p logs uv run python scripts/analysis/fetch_hyperliquid.py # 52 alt Hyperliquid (certify) uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni) uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01 + uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy) uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json) echo "===== done $(date -u '+%H:%M:%SZ') =====" } >> logs/cron_daily.log 2>&1 diff --git a/scripts/live/paper_prevday.py b/scripts/live/paper_prevday.py new file mode 100644 index 0000000..4ca338a --- /dev/null +++ b/scripts/live/paper_prevday.py @@ -0,0 +1,182 @@ +"""FORWARD-MONITOR — PREVDAY RANGE BREAKOUT (lead ortogonale a TP01), forward-only, PAPER. + +NON è esecuzione reale. È il monitoraggio forward-only del LEAD validato dall'onda intraday +(src/strategies/prevday_breakout.py, parametri CONGELATI) per vedere se l'edge in-sample regge +FUORI CAMPIONE VERO nei prossimi mesi. Stesso trattamento di XS01 STAT-MODE / STA05. + +DESIGN (onesto): + - Legge i parquet certificati BTC/ETH 1h (data/raw). Segnale a 1h, libro 50/50. + - Alla prima esecuzione parte dall'ultima barra 1h CHIUSA (forward-only: lo storico NON entra + nel PnL di paper, si traccia solo da ora in avanti). + - Ogni run processa le NUOVE barre 1h chiuse: applica il rendimento della posizione tenuta, + addebita le fee sul turnover, registra i flip di segno, poi ricalcola la posizione-bersaglio. + - Traccia DUE libri in parallelo per onestà sull'esecuzione (lo scettico ha segnalato che a $600 + il micro-ribilanciamento del vol-target ha un haircut di fill): + * MODELED : capitale nominale $2000, ribilanciamento continuo (fee proporzionale su ogni |Δ|). + * REAL-$600: capitale reale $600, salta i ribilanciamenti di nozionale < min_order ($5) — + cosa che il conto vero catturerebbe davvero. Il gap MODELED-REAL = l'haircut di fill reale. + - Per barre fresche, aggiornare prima i dati: + uv run python scripts/analysis/rebuild_history.py --asset BTC ETH + +Stato: data/paper_prevday/{state.json, trades.jsonl, returns.jsonl} (append-only). + + uv run python scripts/live/paper_prevday.py # avanza col dato disponibile + uv run python scripts/live/paper_prevday.py --status # solo stato, non avanza + uv run python scripts/live/paper_prevday.py --reset # azzera (riparte da ora) +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.backtest.harness import load # noqa: E402 +from src.strategies.prevday_breakout import target as prevday_target # noqa: E402 +from src.strategies import prevday_breakout as pb # noqa: E402 + +STATE_DIR = PROJECT_ROOT / "data" / "paper_prevday" +STATE_FILE = STATE_DIR / "state.json" +TRADES_FILE = STATE_DIR / "trades.jsonl" +RETURNS_FILE = STATE_DIR / "returns.jsonl" +ASSETS = ["BTC", "ETH"] +WEIGHT = 0.5 +FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip (Deribit taker) +MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo +REAL_CAPITAL = 600.0 # capitale mainnet reale +MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia + + +def build_bars() -> dict[str, pd.DataFrame]: + return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS} + + +def _state_io(write: dict | None = None): + if write is not None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(write, indent=2)) + return write + return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None + + +def _append(path: Path, rec: dict): + STATE_DIR.mkdir(parents=True, exist_ok=True) + with open(path, "a") as f: + f.write(json.dumps(rec) + "\n") + + +def init_state(dfs) -> dict: + last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) + pos = {a: pb.current_target(dfs[a][dfs[a]["timestamp"] <= last_ts]) for a in ASSETS} + return dict( + start_ts=last_ts, last_ts=last_ts, n_bars=0, + pos_modeled=pos, pos_real=dict(pos), + cap_modeled=MODELED_CAPITAL, cap_real=REAL_CAPITAL, + peak_modeled=MODELED_CAPITAL, peak_real=REAL_CAPITAL, + dd_modeled=0.0, dd_real=0.0, n_trades=0, + ) + + +def advance(st: dict, dfs: dict) -> dict: + data = {} + for a in ASSETS: + df = dfs[a] + c = df["close"].values.astype(float) + r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0 + data[a] = dict(ts=df["timestamp"].values.astype("int64"), + dt=pd.to_datetime(df["datetime"]).values, r=r, + tgt=prevday_target(df)) + common = sorted(set(data["BTC"]["ts"]).intersection(data["ETH"]["ts"])) + new_ts = [t for t in common if t > st["last_ts"]] + if not new_ts: + return st + idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS} + pm, pr = dict(st["pos_modeled"]), dict(st["pos_real"]) + cm, cr = st["cap_modeled"], st["cap_real"] + pkm, pkr = st["peak_modeled"], st["peak_real"] + ddm, ddr = st["dd_modeled"], st["dd_real"] + ntr = st.get("n_trades", 0) + + for t in new_ts: + net_m = net_r = 0.0 + nm, nr = {}, {} + for a in ASSETS: + i = idx[a][int(t)] + r = float(data[a]["r"][i]); tgt = float(data[a]["tgt"][i]) + # MODELED: continuous rebalance + hm = pm[a] + net_m += WEIGHT * (hm * r - FEE_SIDE * abs(tgt - hm)) + nm[a] = tgt + if np.sign(tgt) != np.sign(hm): + _append(TRADES_FILE, dict(ts=int(t), dt=str(pd.Timestamp(data[a]["dt"][i])), + asset=a, action="ENTRY" if tgt != 0 else "EXIT", + from_pos=round(hm, 4), to_pos=round(tgt, 4))) + ntr += 1 + # REAL-$600: skip sub-min_order rebalances + hr = pr[a] + leg_cap = cr * WEIGHT + executed = abs(tgt - hr) * leg_cap >= MIN_ORDER + new_hr = tgt if executed else hr + net_r += WEIGHT * (hr * r - FEE_SIDE * abs(new_hr - hr)) + nr[a] = new_hr + cm *= (1.0 + max(net_m, -0.99)); cr *= (1.0 + max(net_r, -0.99)) + pkm = max(pkm, cm); pkr = max(pkr, cr) + ddm = max(ddm, (pkm - cm) / pkm if pkm > 0 else 0.0) + ddr = max(ddr, (pkr - cr) / pkr if pkr > 0 else 0.0) + pm, pr = nm, nr + _append(RETURNS_FILE, dict(ts=int(t), dt=str(pd.Timestamp(data["BTC"]["dt"][idx["BTC"][int(t)]])), + net_modeled=round(net_m, 6), net_real=round(net_r, 6), + pos_btc=round(pr["BTC"], 4), pos_eth=round(pr["ETH"], 4), + cap_modeled=round(cm, 2), cap_real=round(cr, 2))) + + st.update(last_ts=int(new_ts[-1]), n_bars=st.get("n_bars", 0) + len(new_ts), + pos_modeled=pm, pos_real=pr, cap_modeled=cm, cap_real=cr, + peak_modeled=pkm, peak_real=pkr, dd_modeled=ddm, dd_real=ddr, n_trades=ntr) + return st + + +def print_status(st: dict, dfs: dict): + days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000 + rm = st["cap_modeled"] / MODELED_CAPITAL - 1 + rr = st["cap_real"] / REAL_CAPITAL - 1 + print(f"\n PREVDAY-BREAKOUT forward-monitor (PAPER, lead ortogonale a TP01 — non deploy)") + print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} " + f"({st['n_bars']} barre 1h ~{days:.0f}g) trade(flip): {st['n_trades']}") + print(f" posizione corrente: BTC {st['pos_real']['BTC']:+.3f} ETH {st['pos_real']['ETH']:+.3f}") + print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%") + print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%") + print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)") + print(f" log: {RETURNS_FILE}\n") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--status", action="store_true") + ap.add_argument("--reset", action="store_true") + args = ap.parse_args() + dfs = build_bars() + if args.reset: + for p in (STATE_FILE, TRADES_FILE, RETURNS_FILE): + if p.exists(): + p.unlink() + st = init_state(dfs); _state_io(st) + print("forward-monitor inizializzato (forward-only da ora).") + print_status(st, dfs); return + st = _state_io() + if st is None: + st = init_state(dfs); _state_io(st) + print("forward-monitor inizializzato (forward-only da ora).") + print_status(st, dfs); return + if not args.status: + st = advance(st, dfs); _state_io(st) + print_status(st, dfs) + + +if __name__ == "__main__": + main() diff --git a/src/strategies/prevday_breakout.py b/src/strategies/prevday_breakout.py new file mode 100644 index 0000000..4d65bb2 --- /dev/null +++ b/src/strategies/prevday_breakout.py @@ -0,0 +1,107 @@ +"""PREVDAY RANGE BREAKOUT — LEAD ortogonale a TP01, in FORWARD-MONITOR (NON deployato). + +Stato (2026-06-21): unico segnale sopravvissuto alla verifica avversariale dell'onda intraday +(diario `2026-06-21-intraday-microstructure.md`). Lo scettico d'esecuzione l'ha chiamato "the only +honest candidate, real and conditionally executable". NON è in esecuzione reale: è un LEAD che +teniamo in paper forward-only per vedere se l'edge in-sample regge fuori campione VERO nei prossimi +mesi (stesso trattamento di XS01 STAT-MODE / STA05). Questo modulo CONGELA il segnale (parametri +fissi) così il track record forward è contro una strategia immutabile. + +IL SEGNALE (1h, per-asset, posizione continua vol-targeted): + * Direzione +1 quando close[i] perfora in modo DECISIVO il MAX del giorno UTC precedente + (livello + buffer = k * range di ieri); -1 quando perfora il MIN; si CARRYA 24/7 tra i break + (stop-and-stay, non stop-and-flat -> turnover ~50/anno, non ~500). Long-short SIMMETRICO: la + gamba SHORT è ciò che decorrela da TP01 (TP01 è long-flat, dominato dal beta del toro). + * Vol-target TP01-style (causale, vol trailing) -> la size deriva con la vol. + +ONESTÀ (giudice indurito 2026-06-21): abs PASS, marginal ADDS, earns_slot TRUE, NON-hedge, +multi-cut persistente, leak-free (causality_ok max_tail_diff 0), ROBUST allo shift del confine-giorno +(day_boundary_robust -> non è un artefatto di calendario come open_drive). corr a TP01 ~0.15 full / +~0 hold; Sharpe in-sample ~1.2; hold-out positivo su BTC ~0.9 e ETH ~1.4. CAVEAT: il lift dell'hold-out +della gamba short si appoggia ai regimi down/chop 2025-26; e a $600 il micro-ribilanciamento del +vol-target ha un haircut di fill reale (vedi eval_weights_smallcap). Per questo: FORWARD-MONITOR, +non deploy. Parametri scelti su un plateau (k 0.20-0.30; anchor=1 only). + +CAUSALE: il max/min di ieri usa SOLO barre di giorni STRETTAMENTE precedenti (groupby giorno UTC -> +shift(1)); close[i] è confrontato col livello -> il break è noto a close[i]. Vol trailing. Nessun fit +full-sample. Self-contained (nessuna dipendenza da scripts/research). +""" +from __future__ import annotations + +import numpy as np +import pandas as pd + +# --- parametri CONGELATI (plateau-interior, vedi diario) ------------------------------- +ANCHOR_DAYS = 1 # range = max/min del giorno UTC precedente (1 = "ieri") +BUFFER_K = 0.30 # buffer di break decisivo = k * range di ieri (plateau 0.20-0.30) +ALLOW_SHORT = True # libro SIMMETRICO: la gamba short è ciò che decorrela da TP01 +TARGET_VOL = 0.20 +VOL_WIN_DAYS = 30 +LEV_CAP = 2.0 + + +def _bars_per_day(df: pd.DataFrame) -> int: + dt = pd.to_datetime(df["datetime"]).diff().dt.total_seconds().median() + return max(1, round(86400 / dt)) if dt and dt > 0 else 24 + + +def _vol_target(direction: np.ndarray, df: pd.DataFrame, target_vol: float, + vol_win_days: int, lev_cap: float) -> np.ndarray: + """Scala una direzione in [-1,1] a posizione vol-targeted. Causale (vol trailing).""" + c = df["close"].values.astype(float) + bpd = _bars_per_day(df) + bpy = bpd * 365.25 + r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0 + win = max(2, vol_win_days * bpd) + vol = pd.Series(r).rolling(win, min_periods=max(2, win // 2)).std().values * np.sqrt(bpy) + scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0) + tgt = np.clip(direction * scal, -lev_cap, lev_cap) + tgt[~np.isfinite(tgt)] = 0.0 + return tgt + + +def _prior_day_hilo(df: pd.DataFrame, anchor_days: int): + """MAX(high)/MIN(low) dei `anchor_days` giorni UTC PRECEDENTI, allineati a ogni barra, + noti causalmente (groupby giorno -> rolling -> shift(1) = strettamente < oggi).""" + dt = pd.to_datetime(df["datetime"], utc=True) + day = dt.dt.floor("1D") + g = pd.DataFrame({"day": day.values, + "high": df["high"].values.astype(float), + "low": df["low"].values.astype(float)}) + per_day = g.groupby("day").agg(dh=("high", "max"), dl=("low", "min")) + dh = per_day["dh"].rolling(anchor_days, min_periods=1).max().shift(1) + dl = per_day["dl"].rolling(anchor_days, min_periods=1).min().shift(1) + mapped = pd.DataFrame({"dh": dh, "dl": dl}).reindex(g["day"].values) + return mapped["dh"].values, mapped["dl"].values + + +def _breakout_direction(df: pd.DataFrame, anchor_days: int, buffer_k: float, + allow_short: bool) -> np.ndarray: + c = df["close"].values.astype(float) + pdh, pdl = _prior_day_hilo(df, anchor_days) + rng = pdh - pdl + up_lvl = pdh + buffer_k * rng + dn_lvl = pdl - buffer_k * rng + n = len(c) + dirn = np.zeros(n) + cur = 0.0 + low_state = -1.0 if allow_short else 0.0 + for i in range(n): + if np.isfinite(up_lvl[i]) and c[i] > up_lvl[i]: + cur = 1.0 + elif np.isfinite(dn_lvl[i]) and c[i] < dn_lvl[i]: + cur = low_state + dirn[i] = cur + return dirn + + +def target(df: pd.DataFrame) -> np.ndarray: + """Posizione continua per-asset (1h) in [-LEV_CAP, LEV_CAP], decisa a close[i].""" + direction = _breakout_direction(df, ANCHOR_DAYS, BUFFER_K, ALLOW_SHORT) + pos = _vol_target(direction, df, TARGET_VOL, VOL_WIN_DAYS, LEV_CAP) + return np.nan_to_num(pos, nan=0.0) + + +def current_target(df: pd.DataFrame) -> float: + """Posizione-bersaglio sull'ultima barra (decisa con dati <= ultima barra chiusa).""" + return float(target(df)[-1]) diff --git a/tests/test_prevday_breakout.py b/tests/test_prevday_breakout.py new file mode 100644 index 0000000..cb71614 --- /dev/null +++ b/tests/test_prevday_breakout.py @@ -0,0 +1,33 @@ +"""Locks the FROZEN prevday-breakout lead (src/strategies/prevday_breakout.py) so the +forward-monitor track record is against an immutable signal. Pins: causal, bounded, +non-trivial (long & short), and the frozen params unchanged. +""" +import sys +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from src.backtest.harness import load # noqa: E402 +from src.strategies import prevday_breakout as pb # noqa: E402 + + +def test_frozen_params_unchanged(): + assert (pb.ANCHOR_DAYS, pb.BUFFER_K, pb.ALLOW_SHORT) == (1, 0.30, True) + assert (pb.TARGET_VOL, pb.VOL_WIN_DAYS, pb.LEV_CAP) == (0.20, 30, 2.0) + + +def test_target_causal_bounded_nontrivial(): + df = load("BTC", "1h") + t = pb.target(df) + assert len(t) == len(df) + assert np.all(np.isfinite(t)) + assert np.all(np.abs(t) <= pb.LEV_CAP + 1e-9) + # symmetric book actually takes BOTH sides over history + assert (t > 0).any() and (t < 0).any() + # CAUSAL: recomputing on a truncated prefix matches the full run on its tail + cut = int(len(df) * 0.85) + sub = pb.target(df.iloc[:cut].reset_index(drop=True)) + assert np.max(np.abs(sub[cut - 120:cut] - t[cut - 120:cut])) < 1e-9