research(skh): misura dedicata sugli INGRESSI da barra 230m parziale

Chiude il follow-up dichiarato del 26/07 (misura T1). Il live valuta il segnale
a ogni giro orario del cron su una barra 230m mediamente completa a meta'; il
backtest solo a chiusura di bin. Domanda: di che segno e' il saldo.

Confronto di due path identici in tutto (livelli pct-asimmetrici, uscite
intra-barra, cap max_per_day, fee 0.10% RT) tranne quando si valuta l'ingresso.

  ΔSharpe (live - backtest), 3 offset x 2 asset:
    -0.01 / +0.04 / +0.44 / +0.32 / +0.59 / +0.98  -> 6/6 non negativi, mediana +0.38

Falsi ingressi ~5/anno/asset; cannibalizzano il cap 2-3 volte in 7 anni.
Meccanismo: Donchian breakout — aspettare la chiusura del bin fa pagare il
movimento gia' avvenuto (ingresso 0.25-0.26% peggiore), e su livelli percentuali
quello 0.26% vale il 6-13% della distanza dallo SL contro il 2.6-3.3% da quella
dal TP -> asimmetria a favore della sopravvivenza del trade.

Due attacchi superati:
  - il divario NON e' concentrato: togliendo i 5 giorni migliori si ALLARGA
    (ETH@460 35.6x vs 3.0x); e' il backtest il path concentrato;
  - nessun look-ahead intra-bin: troncando i 5m alle sole barre gia' chiuse la
    risposta e' identica in 289/289 osservazioni (test permanente). Serviva
    perche' il self-check valida solo a CHIUSURA di bin.

Verdetto: non e' un difetto da correggere, il verso e' lasciarlo. Ma live e
backtest girano due strategie diverse e la differenza non e' neutra: sommato
alla misura sulle uscite, il path live di SKH01 e' stato modellato in modo
sistematicamente pessimistico su entrambi i lati.

Book, pesi, cron, config INVARIATI.

Due errori di metodo catturati in sessione e codificati come regole:
  - un self-check su eventi rari si campiona sugli EVENTI (il primo dava
    "80/80 OK" confrontando zeri con zeri, con la ricostruzione rotta);
  - un conteggio su segnale grezzo non e' un conteggio di trade (sovrastima
    ~20x dei falsi ingressi ignorando cap e non-overlap del live).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 22:12:42 +00:00
parent 36d3361075
commit c8d31bc944
4 changed files with 859 additions and 6 deletions
+142
View File
@@ -0,0 +1,142 @@
"""Test della misura sugli INGRESSI da barra 230m parziale (r0726_skh_partial_entry.py).
Il rischio di questa misura non e' sbagliare un numero: e' **misurare la propria aritmetica invece
della strategia**. E' gia' successo in sessione — il primo self-check dava "BTC 80/80 OK" mentre la
ricostruzione era rotta, perche' campionava bin a caso e gli ingressi sono ~2% dei bin: confrontava
zeri con zeri. Questi test blindano le due cose che rendono la misura credibile:
1. la ricostruzione riproduce `skyhook_entries` sui bin che HANNO un ingresso (non su bin a caso);
2. il confine del bin e' gestito bene (un'osservazione ALLA chiusura appartiene al bin che chiude).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
for p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt"):
sys.path.insert(0, str(p))
pytest.importorskip("pandas")
import r0702_anchor_skh01 as r02 # noqa: E402
import r0726_skh_partial_entry as pe # noqa: E402
# --------------------------------------------------------------- confini del bin
def test_osservazione_alla_chiusura_appartiene_al_bin_che_chiude():
"""IL bug della prima stesura: `obs // MS_LTF` alla chiusura salta al bin SUCCESSIVO, che e'
ancora vuoto -> signal_at ritorna None -> il self-check confronta zeri e passa a vuoto."""
start = 1_700_000_000_000 // pe.MS_LTF * pe.MS_LTF
close = start + pe.MS_LTF
assert ((close - 1) // pe.MS_LTF) * pe.MS_LTF == start
assert (close // pe.MS_LTF) * pe.MS_LTF == start + pe.MS_LTF # il bug, documentato
def test_le_osservazioni_stanno_dentro_il_bin_e_non_includono_la_chiusura():
"""Il cron gira a ore piene: le osservazioni INTRA-bin sono quelle strettamente interne.
La chiusura non e' un'osservazione intra-bin (li' il backtest e il live coincidono)."""
start = 1_700_000_000_000 // pe.MS_LTF * pe.MS_LTF
obs = pe.observations(start)
assert obs, "un bin da 230 min contiene almeno un confine orario"
assert all(start < t < start + pe.MS_LTF for t in obs)
assert all(t % pe.MSH == 0 for t in obs), "il cron gira a ore piene"
assert len(obs) in (3, 4), len(obs) # 230 min contiene 3 o 4 confini orari
# --------------------------------------------------------------- fedelta' della ricostruzione
@pytest.mark.parametrize("asset", ["BTC", "ETH"])
def test_ricostruzione_fedele_sui_bin_CON_ingresso(asset):
"""Il test che conta. Se fallisce, ogni numero della misura e' privo di significato."""
r = pe.selfcheck(asset, n_signal=40, n_flat=60)
assert r["sig_tot"] >= 20, "campione troppo piccolo per dire qualcosa"
assert r["sig_ok"] == r["sig_tot"], (asset, r)
def test_il_selfcheck_non_e_vacuo():
"""Deve testare bin che HANNO un ingresso, altrimenti passa anche da rotto."""
r = pe.selfcheck("BTC", n_signal=40, n_flat=10)
assert r["sig_tot"] >= 20
assert r["flat_tot"] <= 10
def test_segnale_a_fine_bin_coincide_col_backtest_su_un_caso_reale():
"""Verifica end-to-end su un bin con ingresso vero: direzione E prezzo di riferimento."""
_, _, ltf, ent = r02.run_asset("BTC", 0)
df5 = r02.get5m("BTC")
ts5 = df5["timestamp"].values.astype(np.int64)
o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float)
l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float)
v5 = df5["volume"].values.astype(float)
htf = r02.resample_off(df5, pe.HTF_MIN, 0)
tsl = ltf["timestamp"].values.astype(np.int64)
ks = [k for k in range(pe.WARM_HTF * 3, len(ltf) - 1) if ent[k]]
assert ks, "nessun ingresso nello storico: il test non direbbe nulla"
k = ks[-1]
s = pe.signal_at(int(tsl[k]) + pe.MS_LTF, ltf, htf, ts5, o5, h5, l5, c5, v5)
assert s is not None
assert s["dir"] == ent[k]["dir"]
assert s["close"] == pytest.approx(float(ltf["close"].values[k]), rel=1e-9)
# --------------------------------------------------------------- assenza di look-ahead
@pytest.mark.parametrize("asset", ["BTC", "ETH"])
def test_nessun_lookahead_intra_bin(asset):
"""IL test che rende credibile il risultato della misura.
Il self-check sui bin CON ingresso valida la ricostruzione **a chiusura di bin**: li' la barra
e' completa, quindi un look-ahead che esistesse SOLO intra-bin gli sarebbe invisibile per
costruzione. E' proprio intra-bin che il live vive, ed e' proprio li' che la misura trova il
vantaggio -> senza questo test il vantaggio potrebbe essere futuro travestito.
Controllo decisivo: se il futuro contasse, troncare i 5m alle sole barre GIA' CHIUSE a obs_ms
cambierebbe la risposta. Deve essere identica, e il prezzo d'ingresso deve coincidere con
l'ultimo close 5m disponibile in quell'istante.
"""
_, _, ltf, ent = r02.run_asset(asset, 0)
df5 = r02.get5m(asset)
ts5 = df5["timestamp"].values.astype(np.int64)
o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float)
l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float)
v5 = df5["volume"].values.astype(float)
htf = r02.resample_off(df5, pe.HTF_MIN, 0)
tsl = ltf["timestamp"].values.astype(np.int64)
ks = [k for k in range(pe.WARM_HTF * 3, len(ltf) - 1) if ent[k]][-8:]
assert ks, "nessun ingresso nello storico: il test non direbbe nulla"
n = 0
for k in ks:
for obs in pe.observations(int(tsl[k])):
full = pe.signal_at(obs, ltf, htf, ts5, o5, h5, l5, c5, v5)
j = int(np.searchsorted(ts5 + pe.MS5, obs, side="right")) # solo barre chiuse
trunc = pe.signal_at(obs, ltf, htf, ts5[:j], o5[:j], h5[:j],
l5[:j], c5[:j], v5[:j])
n += 1
assert (full or {}).get("dir", 0) == (trunc or {}).get("dir", 0), (asset, obs)
assert (full or {}).get("close") == (trunc or {}).get("close"), (asset, obs)
if full:
assert full["close"] == pytest.approx(float(c5[j - 1]), rel=1e-9), (
"il prezzo d'ingresso deve essere gia' noto a obs_ms")
assert n >= 8, f"campione intra-bin troppo piccolo ({n})"
# --------------------------------------------------------------- livelli congelati
def test_livelli_coerenti_col_meccanismo_congelato():
"""SKH01_V2_DD e' pct asimmetrico: long sl4%/tp10%, short sl2%/tp8%."""
sl_l, tp_l, mb_l = pe.levels(+1, 100.0, 1.0)
sl_s, tp_s, mb_s = pe.levels(-1, 100.0, 1.0)
assert sl_l == pytest.approx(96.0) and tp_l == pytest.approx(110.0)
assert sl_s == pytest.approx(102.0) and tp_s == pytest.approx(92.0)
assert (mb_l, mb_s) == (24, 16)
assert (tp_s - 100.0) > (92.0 - 100.0) - 1e-9 # short: TP sotto l'ingresso
def test_finestra_di_warmup_copre_le_memorie_degli_indicatori():
"""La ricostruzione a finestra e' lecita solo se la finestra supera la memoria piu' lunga."""
assert pe.WARM_HTF > max(pe.P.ptn_n, pe.P.n_vola, pe.P.n_volume, pe.P.atr_win) * 3