958 lines
52 KiB
Python
958 lines
52 KiB
Python
#!/usr/bin/env python
|
|
"""r0822b_bocpd.py — IL FALSIFICATORE NOMINATO E MAI ESEGUITO del filone ADAPTIVE-HORIZON.
|
|
|
|
CONTESTO (perche' questo file esiste)
|
|
-------------------------------------
|
|
Il 22/08 `r0822_adaptive_horizon.py` ha scartato il TSMOM a orizzonte adattivo con una diagnosi
|
|
netta: il "vincitore adattivo" aveva L_t **incollato al bordo il 100% del tempo** = un TSMOM a
|
|
orizzonte COSTANTE di 20 giorni travestito (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore);
|
|
isolando le 52 celle davvero adattive -> NEUTRAL, corr->TP01 0.90 = TP01 travestito.
|
|
Ma quell'agente ha dichiarato **cosa lo smentirebbe** e non ha potuto eseguirlo:
|
|
|
|
"Un rilevatore il cui L_t si muove DAVVERO e batte il miglior lookback COSTANTE (non TP01) in
|
|
>=6/8 anni appaiati con corr->TP01 < 0.6. La mia famiglia copre 3 rilevatori a soglia/finestra
|
|
fissa — un run-length posterior bayesiano (BOCPD/PELT vero) o un L scelto per walk-forward sul
|
|
PnL realizzato non sono stati provati."
|
|
|
|
Questo file esegue **quel** test. Oggi il filone e' chiuso su UNA famiglia (3 proxy a soglia
|
|
fissa), non sul MECCANISMO. Una seconda chiusura, con rilevatori veri, rende il "no" definitivo.
|
|
|
|
PERCHE' BOCPD E' STRUTTURALMENTE DIVERSO DAI 3 PROXY DEL PRIMO TENTATIVO
|
|
-----------------------------------------------------------------------
|
|
I 3 proxy (CUSUM a soglia k, variance-ratio, Hurst) producevano una STATISTICA che veniva poi
|
|
MAPPATA linearmente dentro [Lmin, Lmax]: la mappatura e' arbitraria, e infatti la statistica
|
|
saturava contro il clip (bordo 100%). Un run-length posterior NON ha bisogno di mappatura:
|
|
la lunghezza di run **e' gia' un lookback**, nelle stesse unita' (barre). "Quanto indietro posso
|
|
guardare" = "da quanto tempo non cambia il regime" e' un'identita', non una scelta di scala.
|
|
* BOCPD (Adams & MacKay 2007): posterior online su r_t con hazard costante e verosimiglianza
|
|
Normale-InverseGamma coniugata (predittiva Student-t). Causale PER COSTRUZIONE: e' un filtro
|
|
in avanti, ogni P(r_t | x_{1:t}) usa solo x_{1:t}.
|
|
* OP/PELT causale: PELT e' un'accelerazione (pruning) dell'**optimal partitioning**
|
|
F(t) = min_{s<t} [F(s) + C(y_{s+1:t}) + beta]. La ricorsione E' online. ⚠️ La maggior parte
|
|
delle implementazioni di change-point (ruptures.Pelt().fit_predict(intera_serie)) e'
|
|
**retrospettiva** e restituisce la segmentazione dell'INTERO campione = look-ahead colossale.
|
|
Qui F(t) e la sua backtracking-partition sono calcolati SOLO su y[0:t] per ogni t.
|
|
Verificato con `A.causality_ok` **e** con un troncamento manuale esplicito (sezione 0-bis).
|
|
* WF-PnL: L scelto per walk-forward sullo Sharpe NETTO realizzato dei candidati costanti nella
|
|
finestra all'indietro. Non usa alcun modello di regime: e' il "cosa avrebbe funzionato finora".
|
|
|
|
IL NULL GIUSTO NON E' TP01 — E' IL MIGLIOR LOOKBACK COSTANTE
|
|
------------------------------------------------------------
|
|
E' il controllo che ha ucciso il primo tentativo, e va rimesso PRIMA, non dopo: senza di esso
|
|
il null confonde "adattare paga" con "un orizzonte piu' corto paga su questo campione".
|
|
Percio' il confronto principale e': adattivo (cella scelta in-sample) vs miglior L COSTANTE
|
|
(cella scelta in-sample, stessa geometria, stesso TF, stesse fee), **appaiato per ANNO e per
|
|
ANCORA** (mediana delle DIFFERENZE APPAIATE, mai differenza delle mediane — lezione 26/07).
|
|
|
|
FAMIGLIA DICHIARATA **PRIMA** DI GUARDARE (conteggio al RIALZO)
|
|
--------------------------------------------------------------
|
|
A) rilevatori veri 2 (bocpd | op)
|
|
parametro 3 (bocpd: hazard lambda 50/100/250 g ; op: penalita' k 2/5/10)
|
|
statistica 2 (bocpd: E[r_t] | argmax r_t ; op: eta' dall'ultimo cp | mediana
|
|
delle lunghezze di segmento gia' concluse)
|
|
mappatura 2 (direct | inverse) [il verso non e' ovvio a priori => e' un asse]
|
|
blend 2 (single L | triple L/3,L,2L — con L=90 il triple E' TP01)
|
|
timeframe 2 (1d | 12h) [>=12h: sotto, costi+overfit dominano, 19/06]
|
|
= 2*3*2*2*2*2 = **96 trial**
|
|
B) controllo L COSTANTE 12 L x 2 blend x 2 TF = **48 trial**
|
|
C) WF-PnL (L da walk-forward) 3 finestre x 2 blend x 2 TF = **12 trial**
|
|
D) taratura della SCALA 8 penalita' OP + 5 hazard BOCPD = **13** (sezione 1-pre)
|
|
---------------------------------------------------------------- TOTALE DICHIARATO **169**
|
|
⚠️ (D) e' una scansione fatta guardando SOLO il numero di change-point rilevati — una proprieta'
|
|
del RILEVATORE — e mai uno Sharpe: nessuna di quelle 13 configurazioni produce un rendimento. Si
|
|
dichiara lo stesso, al rialzo, perche' ha ristretto la griglia di (A). Il suo esito e' gia' un
|
|
risultato: a penalita' OP >= 5 l'eta' resta incollata al tetto della finestra il 44% del tempo,
|
|
cioe' **l'artefatto del 22/08 riprodotto su un rilevatore vero** — e la griglia (A) usa solo la
|
|
regione in cui il rilevatore rileva davvero (0.5/1.0/2.0 = 18-40 change-point l'anno).
|
|
Costanti FISSATE A PRIORI (gradi di liberta' non cercati, dichiarati perche' esistono):
|
|
Lmin=20g, Lmax=200g (contengono 30..180 del canonico e sono gli stessi del primo tentativo);
|
|
BOCPD su rendimenti log STANDARDIZZATI da vol rolling causale 30g (il bersaglio e' il DRIFT, non
|
|
la vol); prior NIG mu0=0, kappa0=1, alpha0=1, beta0=1; troncamento del posterior a Lmax barre;
|
|
costo OP = mean-shift gaussiano su dati standardizzati, penalita' beta = k*log(t) causale.
|
|
Il deflated-Sharpe e' riportato a N=169 (mia famiglia), N=281 (mia + le 112 del primo tentativo:
|
|
**stessa domanda**, i trial si sommano) e N=449 (di SCREEN — lezione 22/08 del filone ORTHO:
|
|
su BTC/ETH direzionale il massimo atteso dal rumore su 168 trial e' Sharpe 1.572, SOPRA il
|
|
soffitto misurato ~1.3, quindi il conteggio di screen e' quello che decide).
|
|
|
|
ORDINE DI ESECUZIONE (imposto dalla missione, ed e' l'ordine giusto)
|
|
--------------------------------------------------------------------
|
|
La diagnostica che ha smascherato il primo tentativo va PER PRIMA: quanto si muove davvero L_t?
|
|
Se sta al bordo, l'artefatto e' riprodotto e il filone si chiude di nuovo senza guardare i
|
|
rendimenti. Solo se L_t si muove ha senso misurare se il movimento PAGA.
|
|
|
|
Runtime atteso ~6-10 min su 2 core. Nessun file toccato fuori da questo.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from scipy.special import gammaln
|
|
from scipy.stats import norm
|
|
|
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import altlib as A # noqa: E402
|
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
|
|
|
ASSETS = A.CERTIFIED
|
|
HOLDOUT = A.HOLDOUT
|
|
|
|
# ---- costanti FISSATE A PRIORI (dichiarate sopra, non cercate) -------------------------
|
|
LMIN_D, LMAX_D = 20, 200 # banda dell'orizzonte, IDENTICA al primo tentativo
|
|
FIXED_H = (30, 90, 180) # == (L/3, L, 2L) con L=90 -> TP01 canonico
|
|
TGT_VOL, LEV, VOLWIN = 0.20, 2.0, 30
|
|
NIG_MU0, NIG_KAP0, NIG_AL0, NIG_BE0 = 0.0, 1.0, 1.0, 1.0
|
|
STD_WIN_D = 30 # finestra della standardizzazione causale dei rendimenti
|
|
|
|
TFS = ("1d", "12h")
|
|
DETECTORS = ("bocpd", "op")
|
|
PARAMS = {"bocpd": (50, 100, 250), "op": (0.5, 1.0, 2.0)}
|
|
# TARATURA DELLA SCALA (sezione 1-pre): scansione ESPLORATIVA fatta guardando SOLO il numero di
|
|
# change-point rilevati — una proprieta' del RILEVATORE — e mai lo Sharpe. Un rilevatore che non
|
|
# rileva nulla non e' un rilevatore: a pen_k>=5 l'eta' resta incollata al tetto della finestra
|
|
# (44% del tempo) = ESATTAMENTE l'artefatto del 22/08, riprodotto qui su un rilevatore vero.
|
|
CAL_PEN = (0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
|
|
CAL_LAM = (25, 50, 100, 250, 500)
|
|
STATS = {"bocpd": ("mean", "map"), "op": ("age", "segmed")}
|
|
MAPPINGS = ("direct", "inverse")
|
|
BLENDS = ("single", "triple")
|
|
|
|
CONST_L = (5, 7, 10, 15, 20, 30, 45, 60, 90, 120, 150, 200)
|
|
WF_WIN_D = (180, 365, 730)
|
|
|
|
N_DECLARED_A = len(DETECTORS) * 3 * 2 * len(MAPPINGS) * len(BLENDS) * len(TFS) # 96
|
|
N_DECLARED_B = len(CONST_L) * len(BLENDS) * len(TFS) # 48
|
|
N_DECLARED_C = len(WF_WIN_D) * len(BLENDS) * len(TFS) # 12
|
|
N_DECLARED_CAL = len(CAL_PEN) + len(CAL_LAM) # 13 (taratura)
|
|
N_DECLARED = N_DECLARED_A + N_DECLARED_B + N_DECLARED_C + N_DECLARED_CAL # 169
|
|
N_PRIOR_FAMILY = 112 # trial dichiarati da r0822_adaptive_horizon (stessa domanda)
|
|
N_SCREEN = 168 # pool di screen direzionale BTC/ETH dell'ondata (filone 12)
|
|
|
|
|
|
# =======================================================================================
|
|
# RILEVATORI VERI
|
|
# =======================================================================================
|
|
def _logret(c: np.ndarray) -> np.ndarray:
|
|
r = np.zeros(len(c))
|
|
r[1:] = np.log(c[1:] / c[:-1])
|
|
return r
|
|
|
|
|
|
def _std_returns(c: np.ndarray, bpd: int) -> np.ndarray:
|
|
"""Rendimenti log standardizzati da vol rolling CAUSALE (min_periods -> warmup a 0).
|
|
Il bersaglio del rilevatore e' il cambio di DRIFT: senza standardizzare, un cambio di
|
|
regime di VOLATILITA' dominerebbe la verosimiglianza e il rilevatore misurerebbe la vol."""
|
|
lr = _logret(c)
|
|
w = max(5, STD_WIN_D * bpd)
|
|
sig = pd.Series(lr).rolling(w, min_periods=max(5, w // 4)).std().values
|
|
y = np.where((sig > 0) & np.isfinite(sig), lr / np.where(sig > 0, sig, 1.0), 0.0)
|
|
return np.nan_to_num(np.clip(y, -8.0, 8.0))
|
|
|
|
|
|
def _student_logpdf(x: float, mu: np.ndarray, kap: np.ndarray,
|
|
al: np.ndarray, be: np.ndarray) -> np.ndarray:
|
|
"""Predittiva Student-t della Normale-InverseGamma coniugata (Murphy 2007, eq. 99-100):
|
|
x | mu,kappa,alpha,beta ~ t_{2 alpha}(mu, beta(kappa+1)/(alpha kappa))."""
|
|
nu = 2.0 * al
|
|
s2 = be * (kap + 1.0) / (al * kap)
|
|
z = (x - mu) ** 2 / (nu * s2)
|
|
return (gammaln(0.5 * (nu + 1.0)) - gammaln(0.5 * nu)
|
|
- 0.5 * np.log(nu * np.pi * s2) - 0.5 * (nu + 1.0) * np.log1p(z))
|
|
|
|
|
|
def _bocpd(y: np.ndarray, lam_bars: float, rmax: int) -> tuple[np.ndarray, np.ndarray]:
|
|
"""BOCPD (Adams & MacKay 2007) con hazard COSTANTE H = 1/lam_bars e verosimiglianza NIG.
|
|
Ritorna (E[r_t], argmax_r P(r_t)) in BARRE. Filtro in avanti: ogni valore usa solo il
|
|
passato -> causale per costruzione, e stabile su prefisso (nessuna statistica full-sample).
|
|
Il posterior e' troncato a rmax barre e rinormalizzato (il troncamento e' un limite di
|
|
memoria, NON un clip sul valore: se la massa si accumula a rmax lo si vede in diagnostica)."""
|
|
n = len(y)
|
|
haz = 1.0 / max(2.0, float(lam_bars))
|
|
R = np.array([1.0])
|
|
mu = np.array([NIG_MU0]); kap = np.array([NIG_KAP0])
|
|
al = np.array([NIG_AL0]); be = np.array([NIG_BE0])
|
|
rl = np.arange(1)
|
|
mean_rl = np.zeros(n); map_rl = np.zeros(n)
|
|
for t in range(n):
|
|
x = float(y[t])
|
|
lp = _student_logpdf(x, mu, kap, al, be)
|
|
lp -= lp.max()
|
|
pred = np.exp(lp)
|
|
gr = R * pred * (1.0 - haz)
|
|
cp = float(np.sum(R * pred * haz))
|
|
m_new = min(len(R) + 1, rmax + 1)
|
|
newR = np.empty(m_new)
|
|
newR[0] = cp
|
|
newR[1:] = gr[:m_new - 1]
|
|
s = newR.sum()
|
|
if not np.isfinite(s) or s <= 0:
|
|
newR = np.zeros(m_new); newR[0] = 1.0; s = 1.0
|
|
newR /= s
|
|
# aggiornamento delle statistiche sufficienti (run r -> run r+1), prior in testa
|
|
mu_f = np.concatenate(([NIG_MU0], (kap * mu + x) / (kap + 1.0)))[:m_new]
|
|
kap_f = np.concatenate(([NIG_KAP0], kap + 1.0))[:m_new]
|
|
al_f = np.concatenate(([NIG_AL0], al + 0.5))[:m_new]
|
|
be_f = np.concatenate(([NIG_BE0], be + kap * (x - mu) ** 2 / (2.0 * (kap + 1.0))))[:m_new]
|
|
R, mu, kap, al, be = newR, mu_f, kap_f, al_f, be_f
|
|
if len(rl) != m_new:
|
|
rl = np.arange(m_new, dtype=float)
|
|
mean_rl[t] = float(np.dot(R, rl))
|
|
map_rl[t] = float(rl[int(np.argmax(R))])
|
|
return mean_rl, map_rl
|
|
|
|
|
|
def _op_causal(y: np.ndarray, pen_k: float, smax: int) -> tuple[np.ndarray, np.ndarray]:
|
|
"""OPTIMAL PARTITIONING **online** (l'obiettivo esatto che PELT accelera con il pruning):
|
|
F(t) = min_{s in [t-smax, t-1]} [ F(s) + C(y[s:t]) + beta(t) ]
|
|
con C = costo gaussiano di mean-shift su dati standardizzati e beta(t) = pen_k*log(t).
|
|
⚠️ Il termine sum(y^2) del costo gaussiano e' INVARIANTE alla partizione (ogni punto sta in
|
|
esattamente un segmento) -> si omette dall'argmin. C(s:t) = -(S[t]-S[s])^2/(t-s).
|
|
Ritorna (eta' dall'ultimo change-point, mediana delle lunghezze dei segmenti CONCLUSI),
|
|
entrambe in barre. Per ogni t si usa SOLO y[0:t]: e' la differenza fra questo e un
|
|
`ruptures.Pelt().fit_predict(y)` retrospettivo, che guarda tutto il campione."""
|
|
n = len(y)
|
|
S = np.concatenate(([0.0], np.cumsum(y)))
|
|
F = np.full(n + 1, np.inf)
|
|
F[0] = 0.0
|
|
prev = np.zeros(n + 1, dtype=int)
|
|
age = np.zeros(n); segmed = np.zeros(n)
|
|
for t in range(1, n + 1):
|
|
a = max(0, t - smax)
|
|
s_idx = np.arange(a, t)
|
|
seg = (S[t] - S[a:t])
|
|
ln = (t - s_idx).astype(float)
|
|
cand = F[a:t] - seg * seg / ln
|
|
j = int(np.argmin(cand))
|
|
beta = pen_k * np.log(max(2.0, float(t)))
|
|
F[t] = cand[j] + beta
|
|
prev[t] = int(s_idx[j])
|
|
# backtracking della partizione VALIDA A t (cap a 40 segmenti: ci serve la mediana)
|
|
last = prev[t]
|
|
age[t - 1] = t - last
|
|
lens = []
|
|
u = last
|
|
while u > 0 and len(lens) < 40:
|
|
p = prev[u]
|
|
lens.append(u - p)
|
|
u = p
|
|
segmed[t - 1] = float(np.median(lens)) if lens else float(t)
|
|
return age, segmed
|
|
|
|
|
|
_STAT_CACHE: dict = {}
|
|
|
|
|
|
def _stat_key(df: pd.DataFrame, det: str, par) -> tuple:
|
|
"""Chiave chiusa sul CONTENUTO del frame (lunghezza + primo/ultimo timestamp): un PREFISSO
|
|
non deve prendere la cache del campione pieno, altrimenti `causality_ok` verrebbe ingannata
|
|
dalla cache invece che dal codice."""
|
|
ts = df["timestamp"].values
|
|
return (det, par, len(df), int(ts[0]), int(ts[-1]))
|
|
|
|
|
|
def detector_stats(df: pd.DataFrame, det: str, par) -> dict:
|
|
key = _stat_key(df, det, par)
|
|
if key in _STAT_CACHE:
|
|
return _STAT_CACHE[key]
|
|
bpd = A.bars_per_day(df)
|
|
c = df["close"].values.astype(float)
|
|
y = _std_returns(c, bpd)
|
|
rmax = max(4, LMAX_D * bpd)
|
|
if det == "bocpd":
|
|
m, mp = _bocpd(y, float(par) * bpd, rmax)
|
|
out = {"mean": m, "map": mp}
|
|
elif det == "op":
|
|
ag, sm = _op_causal(y, float(par), rmax)
|
|
out = {"age": ag, "segmed": sm}
|
|
else:
|
|
raise ValueError(det)
|
|
_STAT_CACHE[key] = out
|
|
return out
|
|
|
|
|
|
def lookback_bars(df: pd.DataFrame, det: str, par, stat: str, mapping: str) -> np.ndarray:
|
|
"""L_t in BARRE dentro [Lmin, Lmax] giorni. `direct` = la lunghezza di run E' il lookback
|
|
(dopo un change-point si puo' guardare indietro solo fin la'); `inverse` e' lo specchio,
|
|
dichiarato come asse perche' il verso non e' ovvio a priori."""
|
|
bpd = A.bars_per_day(df)
|
|
lo, hi = max(2, LMIN_D * bpd), max(3, LMAX_D * bpd)
|
|
v = detector_stats(df, det, par)[stat]
|
|
base = np.clip(np.nan_to_num(v, nan=float(lo)), lo, hi)
|
|
L = base if mapping == "direct" else (lo + hi - base)
|
|
return np.clip(np.round(L), 2, hi).astype(int)
|
|
|
|
|
|
# =======================================================================================
|
|
# DIREZIONE TSMOM (orizzonte costante o variabile) + vol-target — IDENTICA al primo tentativo
|
|
# =======================================================================================
|
|
def _sign_at(c: np.ndarray, h: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
n = len(c)
|
|
i = np.arange(n)
|
|
j = i - h
|
|
ok = j >= 0
|
|
s = np.zeros(n)
|
|
jj = np.where(ok, j, 0)
|
|
s[ok] = np.sign(c[i[ok]] / c[jj[ok]] - 1.0)
|
|
return s, ok
|
|
|
|
|
|
def _dir_from_L(c: np.ndarray, L: np.ndarray, blend: str) -> np.ndarray:
|
|
hs = [L] if blend == "single" else [
|
|
np.maximum(2, np.round(L / 3.0).astype(int)), L, np.round(2.0 * L).astype(int)]
|
|
acc = np.zeros(len(c)); cnt = np.zeros(len(c))
|
|
for h in hs:
|
|
s, ok = _sign_at(c, np.asarray(h, int))
|
|
acc[ok] += s[ok]; cnt[ok] += 1
|
|
out = np.zeros(len(c)); nz = cnt > 0
|
|
out[nz] = acc[nz] / cnt[nz]
|
|
return out
|
|
|
|
|
|
def fixed_target(df: pd.DataFrame) -> np.ndarray:
|
|
"""BASELINE: TSMOM fisso 30/90/180, long-flat, vol-target 20%, leva 2x = TP01 canonico."""
|
|
c = df["close"].values.astype(float)
|
|
bpd = A.bars_per_day(df)
|
|
acc = np.zeros(len(c)); cnt = np.zeros(len(c))
|
|
for hd in FIXED_H:
|
|
s, ok = _sign_at(c, np.full(len(c), hd * bpd, int))
|
|
acc[ok] += s[ok]; cnt[ok] += 1
|
|
d = np.zeros(len(c)); nz = cnt > 0
|
|
d[nz] = acc[nz] / cnt[nz]
|
|
return A.vol_target(np.clip(d, 0, None), df, TGT_VOL, VOLWIN, LEV)
|
|
|
|
|
|
def const_factory(tf: str, L: int, blend: str):
|
|
def fn(df: pd.DataFrame) -> np.ndarray:
|
|
c = df["close"].values.astype(float)
|
|
bpd = A.bars_per_day(df)
|
|
Lb = np.full(len(c), max(2, int(round(L * bpd))), int)
|
|
return A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
|
|
fn.__name__ = f"const_L{L}_{blend}"
|
|
return fn
|
|
|
|
|
|
def factory(tf: str, det: str, par, stat: str, mapping: str, blend: str):
|
|
def fn(df: pd.DataFrame) -> np.ndarray:
|
|
c = df["close"].values.astype(float)
|
|
L = lookback_bars(df, det, par, stat, mapping)
|
|
return A.vol_target(np.clip(_dir_from_L(c, L, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
|
|
fn.__name__ = f"adapt_{det}{par}_{stat}_{mapping}_{blend}"
|
|
return fn
|
|
|
|
|
|
_WF_CACHE: dict = {}
|
|
|
|
|
|
def wf_picks(df: pd.DataFrame, win_d: int, blend: str):
|
|
"""(C) L scelto per WALK-FORWARD sul PnL NETTO realizzato: a ogni barra si prende l'L che,
|
|
fra i candidati COSTANTI, ha lo Sharpe netto migliore nella finestra all'indietro `win_d`.
|
|
Nessun modello di regime: e' il "cosa avrebbe funzionato finora".
|
|
Causale: il PnL della barra i si conosce solo a fine barra i, quindi per decidere a i si usa
|
|
la finestra che finisce a i-1; `eval_weights` shifta poi la posizione di un'altra barra."""
|
|
cands = [L for L in CONST_L if LMIN_D <= L <= LMAX_D]
|
|
key = (win_d, blend) + _stat_key(df, "wf", 0)[2:]
|
|
if key in _WF_CACHE:
|
|
return _WF_CACHE[key]
|
|
c = df["close"].values.astype(float)
|
|
bpd = A.bars_per_day(df)
|
|
n = len(c)
|
|
r = A.simple_returns(c)
|
|
w = max(20, win_d * bpd)
|
|
tgts, sh = [], []
|
|
for L in cands:
|
|
Lb = np.full(n, max(2, int(round(L * bpd))), int)
|
|
t_ = A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV)
|
|
pos = np.zeros(n); pos[1:] = t_[:-1]
|
|
net = pos * r - A.FEE_SIDE * np.abs(np.diff(pos, prepend=0.0))
|
|
ss = pd.Series(net)
|
|
mu = ss.rolling(w, min_periods=w // 2).mean()
|
|
sd = ss.rolling(w, min_periods=w // 2).std()
|
|
with np.errstate(invalid="ignore", divide="ignore"):
|
|
sr = (mu / sd).values
|
|
sr = np.concatenate(([np.nan], sr[:-1])) # PnL noto solo a fine barra
|
|
tgts.append(t_); sh.append(np.where(np.isfinite(sr), sr, -np.inf))
|
|
SH = np.vstack(sh); TG = np.vstack(tgts)
|
|
idx = np.argmax(SH, axis=0)
|
|
idx[~np.isfinite(SH).any(axis=0)] = cands.index(90) # warmup -> il canonico, non il migliore
|
|
out = (np.asarray([cands[i] for i in idx]), TG[idx, np.arange(n)], cands)
|
|
_WF_CACHE[key] = out
|
|
return out
|
|
|
|
|
|
def wf_factory(tf: str, win_d: int, blend: str):
|
|
def fn(df: pd.DataFrame) -> np.ndarray:
|
|
return wf_picks(df, win_d, blend)[1]
|
|
fn.__name__ = f"wfpnl_{win_d}_{blend}"
|
|
return fn
|
|
|
|
|
|
GRID_A = [dict(det=d, par=p, stat=s, mapping=m, blend=b)
|
|
for d in DETECTORS for p in PARAMS[d] for s in STATS[d]
|
|
for m in MAPPINGS for b in BLENDS]
|
|
GRID_B = [dict(L=L, blend=b) for L in CONST_L for b in BLENDS]
|
|
GRID_C = [dict(win_d=w, blend=b) for w in WF_WIN_D for b in BLENDS]
|
|
|
|
|
|
# =======================================================================================
|
|
# UTILITA'
|
|
# =======================================================================================
|
|
def _dd(s: pd.Series) -> float:
|
|
eq = np.cumprod(1.0 + np.asarray(s.dropna().values, float))
|
|
pk = np.maximum.accumulate(eq)
|
|
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
|
|
|
|
|
def _cagr(s: pd.Series) -> float:
|
|
s = s.dropna()
|
|
if len(s) < 5:
|
|
return float("nan")
|
|
eq = float(np.prod(1.0 + s.values))
|
|
yrs = (s.index[-1] - s.index[0]).total_seconds() / (86400 * 365.25)
|
|
return eq ** (1 / max(yrs, 1e-6)) - 1.0 if eq > 0 else -1.0
|
|
|
|
|
|
def resample_offset(df_1h: pd.DataFrame, hours: int, off: int) -> pd.DataFrame:
|
|
"""Stessa convenzione di trend_portfolio.resample_tf, ancora spostata di `off` ore.
|
|
NB `offset=`, non `origin=` (pandas 2.x ignora origin in silenzio — lezione 02/07)."""
|
|
g = df_1h.copy()
|
|
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
|
idx.name = "dt"
|
|
g.index = idx
|
|
out = g.resample(f"{hours}h", offset=pd.Timedelta(hours=off), label="left", closed="left").agg(
|
|
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
|
out = out.dropna(subset=["open"])
|
|
out["datetime"] = out.index
|
|
epoch = pd.Timestamp("1970-01-01", tz="UTC")
|
|
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
|
|
return out.reset_index(drop=True)[
|
|
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
|
|
|
|
|
|
def paired_by_year(cand: pd.Series, base: pd.Series) -> list[dict]:
|
|
J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna()
|
|
rows = []
|
|
for y, g in J.groupby(J.index.year):
|
|
if len(g) < 40:
|
|
continue
|
|
rows.append(dict(year=int(y), n=len(g),
|
|
sh_cand=round(A._sh(g["C"]), 2), sh_base=round(A._sh(g["B"]), 2),
|
|
d_sh=round(A._sh(g["C"]) - A._sh(g["B"]), 2)))
|
|
return rows
|
|
|
|
|
|
def delever_null(cand: pd.Series, base: pd.Series) -> dict:
|
|
"""Esiste k<1 che da' al BASELINE lo stesso maxDD del candidato con Sharpe >= ?
|
|
Se si', il candidato non ha comprato protezione: ha solo tolto leva."""
|
|
J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna()
|
|
dd_c, dd_b = _dd(J["C"]), _dd(J["B"])
|
|
if dd_c >= dd_b or dd_b <= 0:
|
|
return dict(applicabile=False, dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4),
|
|
nota="il candidato NON riduce il maxDD: il null non si applica")
|
|
lo, hi = 0.0, 1.0
|
|
for _ in range(60):
|
|
k = 0.5 * (lo + hi)
|
|
if _dd(k * J["B"]) < dd_c:
|
|
lo = k
|
|
else:
|
|
hi = k
|
|
k = 0.5 * (lo + hi)
|
|
return dict(applicabile=True, k=round(k, 4), dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4),
|
|
sharpe_cand=round(A._sh(J["C"]), 3),
|
|
sharpe_base_delev=round(A._sh(k * J["B"]), 3),
|
|
superato=bool(A._sh(J["C"]) > A._sh(k * J["B"])))
|
|
|
|
|
|
_ANCHOR_CACHE: dict = {}
|
|
|
|
|
|
def series_at_anchor(fn, name: str, hours: int, off: int) -> pd.Series:
|
|
key = (name, hours, off)
|
|
if key not in _ANCHOR_CACHE:
|
|
ser = {}
|
|
for a in ASSETS:
|
|
dfo = resample_offset(A.get(a, "1h"), hours, off)
|
|
ev = A.eval_weights(dfo, A._call_target(fn, dfo, a))
|
|
ser[a] = pd.Series(ev["net"], index=ev["idx"])
|
|
J = pd.concat(ser, axis=1, join="inner").fillna(0.0)
|
|
_ANCHOR_CACHE[key] = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
|
return _ANCHOR_CACHE[key]
|
|
|
|
|
|
def adaptivity(tf: str, det: str, par, stat: str, mapping: str) -> dict:
|
|
"""LA DIAGNOSTICA CHE HA SMASCHERATO IL PRIMO TENTATIVO — quanto si MUOVE davvero L_t.
|
|
Criterio (identico a quello del 22/08, per confrontabilita'): sta a un bordo <50% del tempo
|
|
E l'IQR e' >= 10 giorni. In piu': |dL|/giorno e numero di RESET (cadute di L > 5 giorni),
|
|
che sono la firma di un vero rilevatore di change-point."""
|
|
fl, fh, iq, md, dl, nrs, sat = [], [], [], [], [], [], []
|
|
for a in ASSETS:
|
|
df = A.get(a, tf)
|
|
bpd = A.bars_per_day(df)
|
|
L = lookback_bars(df, det, par, stat, mapping) / bpd
|
|
raw = np.nan_to_num(detector_stats(df, det, par)[stat], nan=0.0) / bpd
|
|
fl.append(float(np.mean(L <= LMIN_D + 1)))
|
|
fh.append(float(np.mean(L >= LMAX_D - 1)))
|
|
iq.append(float(np.percentile(L, 75) - np.percentile(L, 25)))
|
|
md.append(float(np.median(L)))
|
|
dl.append(float(np.median(np.abs(np.diff(L)))))
|
|
d = np.diff(L)
|
|
nrs.append(float(np.sum(d < -5.0) / (len(L) / (365.25)))) # reset all'anno
|
|
sat.append(float(np.mean(raw >= LMAX_D - 1))) # saturazione del GREZZO
|
|
return dict(frac_lo=float(np.mean(fl)), frac_hi=float(np.mean(fh)),
|
|
iqr_days=float(np.mean(iq)), med_days=float(np.mean(md)),
|
|
dL_med=float(np.mean(dl)), reset_yr=float(np.mean(nrs)),
|
|
sat_raw=float(np.mean(sat)),
|
|
adattiva=bool(np.mean(fl) < 0.5 and np.mean(fh) < 0.5 and np.mean(iq) >= 10.0))
|
|
|
|
|
|
def hr(t: str = "") -> None:
|
|
print("\n" + "=" * 86)
|
|
if t:
|
|
print(t)
|
|
print("=" * 86)
|
|
|
|
|
|
def dsr_given_sr0(daily: pd.Series, sr_ann: float, sr0_ann: float,
|
|
dpy: float = 365.25) -> float:
|
|
"""Deflated-Sharpe con il massimo-atteso-dal-rumore IMPOSTO dall'esterno.
|
|
Serve perche' `A.deflated_sharpe` stima sr0 dalla VARIANZA degli Sharpe della griglia: su
|
|
una famiglia OMOGENEA (qui sono tutte varianti di TSMOM, quindi molto correlate) quella
|
|
varianza e' piccola, sr0 esce basso e il DSR esce alto **per costruzione**. Il conteggio che
|
|
decide, sulla ricerca direzionale BTC/ETH, e' quello di SCREEN: il filone ORTHO ha misurato
|
|
che su 168 trial il massimo atteso dal puro rumore e' Sharpe 1.572."""
|
|
r = np.asarray(pd.Series(daily).dropna().values, float)
|
|
T = len(r)
|
|
sr, sr0 = sr_ann / np.sqrt(dpy), sr0_ann / np.sqrt(dpy)
|
|
sk = float(pd.Series(r).skew())
|
|
ku = float(pd.Series(r).kurt()) + 3.0
|
|
den = np.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
|
return float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
|
|
|
|
|
|
def dsr_line(sr: float, allsr: list, daily: pd.Series, label: str, n_decl: int) -> str:
|
|
pad = list(allsr) + [float(np.mean(allsr))] * max(0, n_decl - len(allsr))
|
|
d, s0 = A.deflated_sharpe(sr, pad, daily)
|
|
return (f" N={n_decl:>4} ({label:<24}) DSR={d:.3f} {'PASS' if d >= 0.95 else 'FAIL'}"
|
|
f" max atteso dal rumore {s0:+.3f}")
|
|
|
|
|
|
# =======================================================================================
|
|
def main() -> None:
|
|
t0 = time.time()
|
|
print(__doc__.split("Runtime atteso")[0])
|
|
|
|
# -- 0. SANITY -----------------------------------------------------------------------
|
|
hr("0. SANITY — il baseline 'fisso' e' TP01 canonico (se non lo e', ogni delta e' inventato)")
|
|
tp = TrendPortfolio(**CANONICAL)
|
|
worst = 0.0
|
|
for a in ASSETS:
|
|
df = A.get(a, "1d")
|
|
worst = max(worst, float(np.max(np.abs(fixed_target(df) - tp.target_series(df)))))
|
|
base_1d = A.candidate_daily(fixed_target, tf="1d")
|
|
tp01 = A.tp01_baseline_daily()
|
|
JJ = pd.concat({"a": base_1d, "b": tp01}, axis=1, join="inner").dropna()
|
|
print(f" max|target_mio - TrendPortfolio.target_series| (1d, BTC+ETH) = {worst:.3e}")
|
|
print(f" max|serie_giornaliera_mia - A.tp01_baseline_daily()| = "
|
|
f"{float(np.max(np.abs(JJ['a'] - JJ['b']))):.3e} (n={len(JJ)})")
|
|
assert worst < 1e-12, "il baseline NON riproduce TP01: fermarsi qui"
|
|
|
|
# -- 0-bis. IL RILEVATORE E' CAUSALE? (troncamento MANUALE, prima dei gate) -----------
|
|
hr("0-bis. TRONCAMENTO MANUALE — la trappola del change-point RETROSPETTIVO")
|
|
print(" Un `Pelt().fit_predict(serie_intera)` restituisce la segmentazione dell'INTERO")
|
|
print(" campione: al tempo t 'saprebbe' dei cambi futuri. Qui BOCPD e OP sono ricorsioni in")
|
|
print(" avanti. Prova diretta: ricalcolo su un PREFISSO e confronto la coda con il pieno.")
|
|
for det, par in (("bocpd", 100), ("op", 5.0)):
|
|
for stat in STATS[det]:
|
|
wmax = 0.0
|
|
for a in ASSETS:
|
|
df = A.get(a, "1d")
|
|
full = lookback_bars(df, det, par, stat, "direct")
|
|
for frac in (0.70, 0.85):
|
|
cut = int(len(df) * frac)
|
|
sub = df.iloc[:cut].reset_index(drop=True)
|
|
pre = lookback_bars(sub, det, par, stat, "direct")
|
|
wmax = max(wmax, float(np.max(np.abs(pre[-100:] - full[cut - 100:cut]))))
|
|
print(f" {det:<6} {str(par):>5} {stat:<7}: max|L(prefisso)-L(pieno)| sulla coda "
|
|
f"= {wmax:.3e} barre -> {'CAUSALE' if wmax < 1e-9 else 'SOSPETTO'}")
|
|
|
|
# -- 1-pre. TARATURA DELLA SCALA — guardando il RILEVATORE, mai lo Sharpe -----------
|
|
hr("1-pre. TARATURA DELLA SCALA DEI PARAMETRI (guardando SOLO quanti change-point rileva)")
|
|
print(" Un rilevatore che non rileva nulla non e' un rilevatore. Questa scansione guarda il")
|
|
print(" NUMERO di change-point e la distribuzione di L_t — mai un rendimento. Dichiarata")
|
|
print(" come 13 trial in piu' (al rialzo), benche' nessuno produca uno Sharpe.")
|
|
dfc = A.get("BTC", "1d")
|
|
yc = _std_returns(dfc["close"].values.astype(float), 1)
|
|
print(f"\n OP (BTC 1d) {'pen_k':>6} {'cp/anno':>8} {'age med':>8} {'IQR':>7} {'@tetto':>8}")
|
|
for k in CAL_PEN:
|
|
ag, _ = _op_causal(yc, k, LMAX_D)
|
|
ncp = int(np.sum(np.diff(ag) < 0))
|
|
flag = " <= USATO" if k in PARAMS["op"] else (" <= degenere (artefatto 22/08)"
|
|
if np.mean(ag >= LMAX_D - 1) > 0.30 else "")
|
|
print(f" {k:>6} {ncp/(len(yc)/365.25):>8.1f} {np.median(ag):>8.1f} "
|
|
f"{np.percentile(ag,75)-np.percentile(ag,25):>7.1f} "
|
|
f"{np.mean(ag>=LMAX_D-1)*100:>7.1f}%{flag}")
|
|
print(f"\n BOCPD (BTC 1d) {'lambda':>6} {'E[r] med':>9} {'IQR':>7} {'@tetto':>8} "
|
|
f"{'MAP med':>8} {'@tetto':>8}")
|
|
for lam in CAL_LAM:
|
|
mn, mp = _bocpd(yc, float(lam), LMAX_D)
|
|
flag = " <= USATO" if lam in PARAMS["bocpd"] else ""
|
|
print(f" {lam:>6} {np.median(mn):>9.1f} "
|
|
f"{np.percentile(mn,75)-np.percentile(mn,25):>7.1f} "
|
|
f"{np.mean(mn>=LMAX_D-1)*100:>7.1f}% {np.median(mp):>8.1f} "
|
|
f"{np.mean(mp>=LMAX_D-1)*100:>7.1f}%{flag}")
|
|
|
|
# -- 1. LA DIAGNOSTICA DI MECCANISMO, PER PRIMA --------------------------------------
|
|
hr("1. L_t SI MUOVE DAVVERO? (se sta al bordo l'artefatto e' riprodotto: si chiude e basta)")
|
|
print(" criterio (identico al 22/08, per confrontabilita'): a un bordo <50% del tempo E")
|
|
print(" IQR >= 10 giorni. `reset/anno` = cadute di L > 5 giorni = firma di un change-point.")
|
|
print(f"\n {'TF':>4} {'det':>6} {'par':>5} {'stat':>7} {'map':<7} {'bordo-lo':>9} "
|
|
f"{'bordo-hi':>9} {'IQR(g)':>7} {'med(g)':>7} {'|dL|(g)':>8} {'reset/a':>8} "
|
|
f"{'sat.grezzo':>10} esito")
|
|
diag: dict = {}
|
|
for tf in TFS:
|
|
for d in DETECTORS:
|
|
for p in PARAMS[d]:
|
|
for st in STATS[d]:
|
|
for mp in MAPPINGS:
|
|
v = adaptivity(tf, d, p, st, mp)
|
|
diag[(tf, d, p, st, mp)] = v
|
|
print(f" {tf:>4} {d:>6} {str(p):>5} {st:>7} {mp:<7} "
|
|
f"{v['frac_lo']*100:8.1f}% {v['frac_hi']*100:8.1f}% "
|
|
f"{v['iqr_days']:7.1f} {v['med_days']:7.1f} {v['dL_med']:8.2f} "
|
|
f"{v['reset_yr']:8.1f} {v['sat_raw']*100:9.1f}% "
|
|
f"{'ADATTIVA' if v['adattiva'] else 'costante-travestito'}")
|
|
n_ad = sum(1 for v in diag.values() if v["adattiva"])
|
|
print(f"\n configurazioni di rilevatore DAVVERO adattive: {n_ad}/{len(diag)}")
|
|
if n_ad == 0:
|
|
hr("STOP — ARTEFATTO RIPRODOTTO")
|
|
print(" Nessun rilevatore muove L_t: ogni 'vincitore' sarebbe un orizzonte COSTANTE")
|
|
print(" travestito, esattamente come il 22/08. Il filone si chiude per la seconda volta")
|
|
print(" senza bisogno di guardare i rendimenti. FINE.")
|
|
return
|
|
|
|
# -- 2. GRIGLIA (A): cella scelta IN-SAMPLE + deflated-Sharpe -------------------------
|
|
hr(f"2. study_family_honest (A) — {len(GRID_A)} celle x {len(TFS)} TF = "
|
|
f"{len(GRID_A)*len(TFS)} trial")
|
|
rep = A.study_family_honest("BOCPD-H", factory, GRID_A, TFS)
|
|
ch = rep["chosen"]
|
|
print(f" celle valutate : {rep['n_cells']}")
|
|
print(f" cella scelta IN-SAMPLE : tf={ch['tf']} {ch['params']}")
|
|
print(f" Sharpe IS {ch['insample_sharpe']} FULL {ch['full_sharpe']}")
|
|
print(f" deflated-Sharpe (N={rep['n_cells']}): {rep['deflated_sharpe']} "
|
|
f"(max atteso sotto il null {rep['expected_null_max']}) PASS={rep['dsr_pass']}")
|
|
print(f" earns_slot_marginal : {rep['earns_slot_marginal']}")
|
|
print(f" EARNS_SLOT_HONEST : {rep['earns_slot_honest']}")
|
|
print("\n top-10 IN-SAMPLE (come si sceglie onestamente):")
|
|
for r in rep["rows"][:10]:
|
|
ad = diag[(r["tf"], r["params"]["det"], r["params"]["par"],
|
|
r["params"]["stat"], r["params"]["mapping"])]["adattiva"]
|
|
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
|
|
f"{r['params']} {'[adattiva]' if ad else '[costante-travestito]'}")
|
|
|
|
fn_ch = factory(tf=ch["tf"], **ch["params"])
|
|
d_ch = A.candidate_daily(fn_ch, tf=ch["tf"])
|
|
|
|
# -- 3. CONTROLLO (B): il MIGLIOR ORIZZONTE COSTANTE ---------------------------------
|
|
hr(f"3. CONTROLLO L COSTANTE (B) — {len(GRID_B)} celle x {len(TFS)} TF = "
|
|
f"{len(GRID_B)*len(TFS)} trial [IL NULL GIUSTO, non TP01]")
|
|
csel = A.select_cell_insample(const_factory, GRID_B, TFS)
|
|
cch = csel["chosen"]
|
|
print(f" miglior COSTANTE scelto in-sample: tf={cch['tf']} {cch['params']} "
|
|
f"IS {cch['insample_sharpe']} FULL {cch['full_sharpe']}")
|
|
print(" top-8 costanti in-sample (L=90 triple 1d == TP01 canonico):")
|
|
for r in csel["rows"][:8]:
|
|
tag = " <== TP01" if (r["params"]["L"] == 90 and r["params"]["blend"] == "triple"
|
|
and r["tf"] == "1d") else ""
|
|
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
|
|
f"L={r['params']['L']:>3}g {r['params']['blend']}{tag}")
|
|
fn_const = const_factory(tf=cch["tf"], **cch["params"])
|
|
d_const = A.candidate_daily(fn_const, tf=cch["tf"])
|
|
|
|
# -- 4. WF-PnL (C) --------------------------------------------------------------------
|
|
hr(f"4. WALK-FORWARD SUL PnL REALIZZATO (C) — {len(GRID_C)} celle x {len(TFS)} TF = "
|
|
f"{len(GRID_C)*len(TFS)} trial")
|
|
wsel = A.select_cell_insample(wf_factory, GRID_C, TFS)
|
|
wch = wsel["chosen"]
|
|
print(f" miglior WF scelto in-sample: tf={wch['tf']} {wch['params']} "
|
|
f"IS {wch['insample_sharpe']} FULL {wch['full_sharpe']}")
|
|
for r in wsel["rows"]:
|
|
print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} "
|
|
f"{r['params']}")
|
|
fn_wf = wf_factory(tf=wch["tf"], **wch["params"])
|
|
d_wf = A.candidate_daily(fn_wf, tf=wch["tf"])
|
|
# quanto si muove L nella variante WF? (stesso metro della sezione 1)
|
|
for a in ASSETS:
|
|
df = A.get(a, wch["tf"]); bpd = A.bars_per_day(df)
|
|
pick, _, cands = wf_picks(df, **wch["params"])
|
|
print(f" {a}: L_WF giorni mediana {np.median(pick):.0f} IQR "
|
|
f"{np.percentile(pick,75)-np.percentile(pick,25):.0f} "
|
|
f"bordo basso {np.mean(pick<=min(cands))*100:.1f}% "
|
|
f"bordo alto {np.mean(pick>=max(cands))*100:.1f}% "
|
|
f"cambi/anno {np.sum(np.diff(pick)!=0)/(len(pick)/(bpd*365.25)):.0f}")
|
|
|
|
# -- 5. IL CONFRONTO CHE DECIDE ------------------------------------------------------
|
|
hr("5. ADATTIVO vs MIGLIOR COSTANTE (entrambi scelti IN-SAMPLE) — e vs TP01")
|
|
base_ch = A.candidate_daily(fixed_target, tf=ch["tf"])
|
|
for nome, ser in (("BOCPD/OP (A)", d_ch), ("WF-PnL (C)", d_wf),
|
|
("miglior COSTANTE (B)", d_const), ("TP01 30/90/180", base_1d)):
|
|
h = ser[ser.index >= HOLDOUT]
|
|
print(f" {nome:<22} FULL Sh {A._sh(ser):+.3f} HOLD Sh {A._sh(h):+.3f} "
|
|
f"maxDD {_dd(ser)*100:5.2f}% CAGR {_cagr(ser)*100:6.2f}% "
|
|
f"corr->TP01 {pd.concat({'a':ser,'b':tp01},axis=1,join='inner').dropna().corr().iloc[0,1]:+.3f}")
|
|
|
|
for lab, cand, ref, refname in (("A vs B", d_ch, d_const, "miglior costante"),
|
|
("C vs B", d_wf, d_const, "miglior costante"),
|
|
("A vs TP01", d_ch, base_1d, "TP01")):
|
|
J = pd.concat({"C": cand, "B": ref}, axis=1, join="inner").dropna()
|
|
JH = J[J.index >= HOLDOUT]
|
|
rows = paired_by_year(cand, ref)
|
|
ds = [r["d_sh"] for r in rows]
|
|
print(f"\n --- {lab} ({refname}) --- corr {J['C'].corr(J['B']):+.3f}")
|
|
print(f" dSh FULL {A._sh(J['C'])-A._sh(J['B']):+.3f} "
|
|
f"dSh HOLD {A._sh(JH['C'])-A._sh(JH['B']):+.3f}")
|
|
print(" differenza APPAIATA per anno:")
|
|
print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in rows))
|
|
print(f" -> anni con dSh>0: {sum(1 for x in ds if x > 0)}/{len(ds)} "
|
|
f"mediana {np.median(ds):+.2f} media {np.mean(ds):+.2f} "
|
|
f"[criterio del falsificatore: >=6/8 anni]")
|
|
|
|
# decomposizione: quanto del vantaggio su TP01 e' ADATTIVITA' e quanto e' "L piu' corto"?
|
|
JT = pd.concat({"C": d_ch, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna()
|
|
v_tp = A._sh(JT["C"]) - A._sh(JT["B"])
|
|
v_ct = A._sh(JT["K"]) - A._sh(JT["B"])
|
|
print(f"\n DECOMPOSIZIONE del vantaggio dell'adattivo su TP01 (FULL, finestra comune):")
|
|
print(f" adattivo - TP01 = {v_tp:+.3f}")
|
|
print(f" miglior COSTANTE - TP01 = {v_ct:+.3f} <- quota spiegata da 'un L piu' corto'")
|
|
print(f" adattivo - miglior COSTANTE= {v_tp - v_ct:+.3f} <- quota spiegata dall'ADATTARE")
|
|
print(f" => l'adattivita' spiega il {100*(v_tp-v_ct)/v_tp if abs(v_tp)>1e-9 else float('nan'):+.0f}% "
|
|
f"del vantaggio su TP01")
|
|
|
|
# la superficie di L costante e' una CURVA o un CRINALE DI RUMORE? (replica del 22/08)
|
|
rows12 = {r["params"]["L"]: r["insample_sharpe"] for r in csel["rows"]
|
|
if r["tf"] == cch["tf"] and r["params"]["blend"] == cch["params"]["blend"]}
|
|
Ls = sorted(rows12)
|
|
adj = float(np.median(np.abs(np.diff([rows12[L] for L in Ls]))))
|
|
print(f"\n RISOLUZIONE della griglia costante ({cch['tf']}, {cch['params']['blend']}): salto IS "
|
|
f"mediano fra L ADIACENTI {adj:+.3f}")
|
|
print(f" contro il vantaggio del miglior costante su TP01 ({v_ct:+.3f}) -> rapporto "
|
|
f"{adj/max(1e-9, abs(v_ct)):.2f} [replica del 'crinale di rumore' del 22/08]")
|
|
|
|
# -- 6. GATE --------------------------------------------------------------------------
|
|
hr("6. GATE sul candidato (A) scelto in-sample")
|
|
print(A.fmt_marginal(rep["marginal"]))
|
|
cz = A.causality_ok(fn_ch, tf=ch["tf"])
|
|
print(f"\n causality_ok : ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} "
|
|
f"(prefissi controllati {cz['checked']})")
|
|
imp = A.implausible_sharpe(d_ch)
|
|
print(f" implausible_sharpe : implausible={imp['implausible']} Sh {imp.get('sharpe')} "
|
|
f"maxDD {imp.get('maxdd')} perdite/attive {imp.get('loss_frac')} "
|
|
f"attive {imp.get('active_frac')}")
|
|
for rr in imp["reasons"]:
|
|
print(f" - {rr}")
|
|
print(f" null de-levering vs COSTANTE : {delever_null(d_ch, d_const)}")
|
|
print(f" null de-levering vs TP01 : {delever_null(d_ch, base_1d)}")
|
|
print(" haircut a $600 (min-order $5):")
|
|
for a in ASSETS:
|
|
df = A.get(a, ch["tf"])
|
|
sc = A.eval_weights_smallcap(df, A._call_target(fn_ch, df, a), capital=600.0)
|
|
print(f" {a}: modellato {sc['modeled']['sharpe']:+.3f} -> reale "
|
|
f"{sc['realistic']['sharpe']:+.3f} (haircut {sc['sharpe_haircut']:+.3f}, "
|
|
f"{sc['n_executed_trades']} trade)")
|
|
|
|
# DSR a piu' conteggi: famiglia, famiglia+112 del primo tentativo, di SCREEN
|
|
all_sr = ([r["full_sharpe"] for r in rep["rows"]]
|
|
+ [r["full_sharpe"] for r in csel["rows"]]
|
|
+ [r["full_sharpe"] for r in wsel["rows"]])
|
|
hr("6-bis. DEFLATED-SHARPE — sensibilita' del verdetto al CONTEGGIO (regola 30/07 + 22/08)")
|
|
print(f" Sharpe FULL del candidato (A) = {A._sh(d_ch):+.3f}")
|
|
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "mia famiglia A+B+C", N_DECLARED))
|
|
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "+ le 112 del 22/08", N_DECLARED + N_PRIOR_FAMILY))
|
|
print(dsr_line(A._sh(d_ch), all_sr, d_ch, "di SCREEN (ondata)",
|
|
N_DECLARED + N_PRIOR_FAMILY + N_SCREEN))
|
|
print("\n ⚠️ QUEL 'PASS' NON CERTIFICA NIENTE, e va detto: `deflated_sharpe` stima il massimo")
|
|
print(" atteso dal rumore dalla VARIANZA degli Sharpe della griglia. Qui la griglia e'")
|
|
print(" OMOGENEA (tutte varianti di TSMOM, molto correlate fra loro) -> varianza piccola")
|
|
print(" -> sr0 basso -> DSR alto per COSTRUZIONE. Aumentare N padding con la media alza N")
|
|
print(" ma non la varianza. Il conteggio che decide e' quello di SCREEN, e il filone ORTHO")
|
|
print(" lo ha gia' misurato: su 168 trial di ricerca direzionale BTC/ETH il massimo atteso")
|
|
print(" dal puro rumore e' Sharpe 1.572. Riscritto con QUEL sr0:")
|
|
for nome, ser in (("adattivo (A)", d_ch), ("miglior costante (B)", d_const),
|
|
("TP01 30/90/180", base_1d)):
|
|
print(f" {nome:<22} Sharpe FULL {A._sh(ser):+.3f} vs sr0_screen 1.572 -> "
|
|
f"DSR {dsr_given_sr0(ser, A._sh(ser), 1.572):.3f} "
|
|
f"{'PASS' if dsr_given_sr0(ser, A._sh(ser), 1.572) >= 0.95 else 'FAIL'}")
|
|
|
|
# PROVA DI IDENTITA': la cella "adattiva" al 100% sul bordo E' il costante, bit per bit
|
|
hr("6-ter. PROVA DI IDENTITA' — la cella al bordo NON somiglia a un costante: LO E'")
|
|
deg = [r for r in rep["rows"]
|
|
if diag[(r["tf"], r["params"]["det"], r["params"]["par"], r["params"]["stat"],
|
|
r["params"]["mapping"])]["frac_lo"] >= 0.999]
|
|
if deg:
|
|
dr = max(deg, key=lambda r: r["insample_sharpe"])
|
|
s_deg = A.candidate_daily(factory(tf=dr["tf"], **dr["params"]), tf=dr["tf"])
|
|
s_con = A.candidate_daily(const_factory(tf=dr["tf"], L=LMIN_D,
|
|
blend=dr["params"]["blend"]), tf=dr["tf"])
|
|
Jd = pd.concat({"a": s_deg, "b": s_con}, axis=1, join="inner").dropna()
|
|
print(f" cella 100% al bordo basso: {dr['tf']} {dr['params']} IS {dr['insample_sharpe']}"
|
|
f" FULL {dr['full_sharpe']}")
|
|
print(f" costante L={LMIN_D}g {dr['params']['blend']} {dr['tf']}: "
|
|
f"FULL {A._sh(s_con):+.3f}")
|
|
print(f" max|serie_adattiva - serie_costante| = {float(np.max(np.abs(Jd['a']-Jd['b']))):.3e}"
|
|
f" (n={len(Jd)}) -> {'SONO LA STESSA STRATEGIA' if float(np.max(np.abs(Jd['a']-Jd['b']))) < 1e-12 else 'diverse'}")
|
|
else:
|
|
print(" nessuna cella e' al 100% sul bordo basso (la prova non si applica)")
|
|
|
|
# -- 7. FAMIGLIA RISTRETTA ALLE SOLE CELLE DAVVERO ADATTIVE --------------------------
|
|
hr("7. GATE SULLA SOLA FAMIGLIA DAVVERO ADATTIVA (la domanda della missione, isolata)")
|
|
ad_rows = [r for r in rep["rows"]
|
|
if diag[(r["tf"], r["params"]["det"], r["params"]["par"],
|
|
r["params"]["stat"], r["params"]["mapping"])]["adattiva"]]
|
|
d_ad_keep = fn_ad_keep = best_keep = None
|
|
if not ad_rows:
|
|
print(" NESSUNA cella della famiglia e' davvero adattiva -> vedi sezione 1.")
|
|
else:
|
|
best = max(ad_rows, key=lambda r: r["insample_sharpe"])
|
|
print(f" celle adattive: {len(ad_rows)}/{len(rep['rows'])} miglior IN-SAMPLE: "
|
|
f"tf={best['tf']} {best['params']} IS {best['insample_sharpe']} "
|
|
f"FULL {best['full_sharpe']}")
|
|
fn_ad = factory(tf=best["tf"], **best["params"])
|
|
d_ad = A.candidate_daily(fn_ad, tf=best["tf"])
|
|
sm = A.study_marginal("BOCPD-H (sole adattive)", fn_ad, tf=best["tf"])
|
|
dsr_ad, sr0_ad = A.deflated_sharpe(A._sh(d_ad),
|
|
[r["full_sharpe"] for r in ad_rows], d_ad)
|
|
print(f" marginal={sm['marginal_verdict']} earns_slot={sm['earns_slot']} "
|
|
f"corr->TP01 {sm['marginal'].get('corr_full')}")
|
|
print(f" deflated-Sharpe (N={len(ad_rows)}) = {dsr_ad:.3f} "
|
|
f"{'PASS' if dsr_ad >= 0.95 else 'FAIL'} (null max atteso {sr0_ad:+.3f})")
|
|
JA = pd.concat({"C": d_ad, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna()
|
|
JH = JA[JA.index >= HOLDOUT]
|
|
print(f" FULL adattiva-vera {A._sh(JA['C']):+.3f} | costante {A._sh(JA['K']):+.3f} "
|
|
f"| TP01 {A._sh(JA['B']):+.3f}")
|
|
print(f" HOLD-OUT adattiva-vera {A._sh(JH['C']):+.3f} | costante {A._sh(JH['K']):+.3f} "
|
|
f"| TP01 {A._sh(JH['B']):+.3f}")
|
|
r3 = paired_by_year(d_ad, d_const)
|
|
d3 = [r["d_sh"] for r in r3]
|
|
print(" vs miglior costante, differenza APPAIATA per anno:")
|
|
print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in r3))
|
|
print(f" -> dSh>0 in {sum(1 for x in d3 if x>0)}/{len(d3)} mediana {np.median(d3):+.2f}"
|
|
f" corr al costante {JA['C'].corr(JA['K']):+.3f}")
|
|
print(" ⚠️ l'hold-out sono gli ULTIMI DUE anni di questa stessa riga: se il vantaggio")
|
|
print(" hold-out sta li' e non nel resto, e' un evento, non un meccanismo (regola:")
|
|
print(" un vantaggio concentrato in un anno non e' un vantaggio).")
|
|
print(f" null de-levering vs costante: {delever_null(d_ad, d_const)}")
|
|
d_ad_keep, fn_ad_keep, best_keep = d_ad, fn_ad, best
|
|
# -- 8. BANDA D'ANCORA (delta APPAIATO adattivo - costante) --------------------------
|
|
hours = 24 if ch["tf"] == "1d" else 12
|
|
offs = list(range(hours))
|
|
hr(f"8. BANDA D'ANCORA — {len(offs)} ancore. Stima onesta = MEDIANA; il confronto fra due "
|
|
f"varianti\n e' la MEDIANA DELLE DIFFERENZE APPAIATE (mai la differenza delle mediane).")
|
|
if cch["tf"] != ch["tf"]:
|
|
print(f" ⚠️ adattivo su {ch['tf']} e miglior costante su {cch['tf']}: per il delta "
|
|
f"appaiato uso il miglior costante RIVALUTATO su {ch['tf']} (stesse ancore = coppie).")
|
|
crow = max([r for r in csel["rows"] if r["tf"] == ch["tf"]],
|
|
key=lambda r: r["insample_sharpe"])
|
|
fn_const_anch = const_factory(tf=ch["tf"], **crow["params"])
|
|
print(f" miglior costante su {ch['tf']}: {crow['params']} IS {crow['insample_sharpe']}")
|
|
else:
|
|
fn_const_anch = fn_const
|
|
s0 = series_at_anchor(fixed_target, "fixed", hours, 0)
|
|
K = pd.concat({"a": s0, "b": base_ch}, axis=1, join="inner").dropna()
|
|
print(f" sanity ancora 0 vs serie canonica del fisso: max|Δ| "
|
|
f"{float(np.max(np.abs(K['a']-K['b']))):.3e} (n={len(K)})")
|
|
for lab, mtr in (("Sharpe FULL", A._sh),
|
|
("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))):
|
|
b_c = A.anchor_luck_band(lambda o: series_at_anchor(fn_ch, "cand", hours, o), offs,
|
|
metric=mtr)
|
|
b_k = A.anchor_luck_band(lambda o: series_at_anchor(fn_const_anch, "const", hours, o),
|
|
offs, metric=mtr)
|
|
print(f" {lab:<12} adattivo: canonica {b_c['canonical']:+.3f} "
|
|
f"(pctl {b_c['canonical_pctl']:.2f}) MEDIANA {b_c['median']:+.3f} "
|
|
f"banda [{b_c['lo']:+.3f},{b_c['hi']:+.3f}]")
|
|
print(f" {'':<12} costante: canonica {b_k['canonical']:+.3f} "
|
|
f"(pctl {b_k['canonical_pctl']:.2f}) MEDIANA {b_k['median']:+.3f} "
|
|
f"banda [{b_k['lo']:+.3f},{b_k['hi']:+.3f}]")
|
|
dl = A.anchor_luck_delta(lambda o: series_at_anchor(fn_ch, "cand", hours, o),
|
|
lambda o: series_at_anchor(fn_const_anch, "const", hours, o),
|
|
offs, metric=mtr)
|
|
print(f" {'':<12} DELTA APPAIATO (adatt - cost): mediana {dl['median_paired']:+.3f} "
|
|
f"positivo in {dl['n_positive']}/{dl['n_anchors']} "
|
|
f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}")
|
|
|
|
# -- 8-bis. ANCORA APPAIATA per la cella DAVVERO ADATTIVA ---------------------------
|
|
if fn_ad_keep is not None and best_keep["tf"] == ch["tf"]:
|
|
hr("8-bis. ANCORA APPAIATA — cella DAVVERO ADATTIVA vs miglior costante")
|
|
for lab, mtr in (("Sharpe FULL", A._sh),
|
|
("Sharpe HOLD", lambda s_: A._sh(s_[s_.index >= HOLDOUT]))):
|
|
dl = A.anchor_luck_delta(
|
|
lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o),
|
|
lambda o: series_at_anchor(fn_const_anch, "const", hours, o), offs, metric=mtr)
|
|
b = A.anchor_luck_band(lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o),
|
|
offs, metric=mtr)
|
|
print(f" {lab:<12} adattiva-vera: canonica {b['canonical']:+.3f} "
|
|
f"(pctl {b['canonical_pctl']:.2f}) MEDIANA {b['median']:+.3f} "
|
|
f"banda [{b['lo']:+.3f},{b['hi']:+.3f}]")
|
|
print(f" {'':<12} DELTA APPAIATO vs costante: mediana {dl['median_paired']:+.3f} "
|
|
f"positivo in {dl['n_positive']}/{dl['n_anchors']} "
|
|
f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}")
|
|
elif fn_ad_keep is not None:
|
|
print(f"\n (8-bis non girata: la cella adattiva sta su {best_keep['tf']} e il costante "
|
|
f"su {ch['tf']} -> le ancore non sarebbero coppie)")
|
|
|
|
# -- 8-ter. IL CRITERIO DEL FALSIFICATORE, SU **TUTTE** LE CELLE ADATTIVE ------------
|
|
hr("8-ter. IL CRITERIO DEL FALSIFICATORE APPLICATO A **OGNI** CELLA DAVVERO ADATTIVA")
|
|
print(" Il 22/08 dichiarava: «un rilevatore il cui L_t si muove DAVVERO e batte il miglior")
|
|
print(" lookback COSTANTE in >=6/8 anni appaiati con corr->TP01 < 0.6». Non basta che")
|
|
print(" fallisca il VINCITORE: si applica il criterio a tutte e", len(ad_rows), "le celle adattive.")
|
|
print(f"\n {'tf':>4} {'cella':<58} {'corr':>6} {'anni>0':>7} {'dShFULL':>8} {'dShHOLD':>8} esito")
|
|
passed = []
|
|
for r in sorted(ad_rows, key=lambda x: -x["insample_sharpe"]):
|
|
fnr = factory(tf=r["tf"], **r["params"])
|
|
dr = A.candidate_daily(fnr, tf=r["tf"])
|
|
JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna()
|
|
cor = float(JJ2["a"].corr(JJ2["b"]))
|
|
yr = paired_by_year(dr, d_const)
|
|
nyp = sum(1 for x in yr if x["d_sh"] > 0)
|
|
Jr = pd.concat({"C": dr, "B": d_const}, axis=1, join="inner").dropna()
|
|
JrH = Jr[Jr.index >= HOLDOUT]
|
|
dF = A._sh(Jr["C"]) - A._sh(Jr["B"])
|
|
dH = A._sh(JrH["C"]) - A._sh(JrH["B"])
|
|
ok = bool(cor < 0.6 and nyp >= 6)
|
|
if ok:
|
|
passed.append((r, cor, nyp, dF, dH))
|
|
lab = (f"{r['params']['det']} {r['params']['par']} {r['params']['stat']} "
|
|
f"{r['params']['mapping']} {r['params']['blend']}")
|
|
print(f" {r['tf']:>4} {lab:<58} {cor:>6.3f} {nyp:>5}/8 {dF:>+8.3f} {dH:>+8.3f} "
|
|
f"{'PASSA' if ok else ''}")
|
|
print(f"\n celle che soddisfano ENTRAMBE le condizioni del falsificatore: "
|
|
f"{len(passed)}/{len(ad_rows)}")
|
|
cors = []
|
|
for r in ad_rows:
|
|
dr = A.candidate_daily(factory(tf=r["tf"], **r["params"]), tf=r["tf"])
|
|
JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna()
|
|
cors.append(float(JJ2["a"].corr(JJ2["b"])))
|
|
print(f" corr->TP01 sulle celle adattive: min {min(cors):.3f} mediana "
|
|
f"{np.median(cors):.3f} max {max(cors):.3f} (soglia del falsificatore 0.60)")
|
|
|
|
# -- 9. FEE SWEEP ---------------------------------------------------------------------
|
|
hr("9. FEE SWEEP — l'adattivo trada di piu': e' li' che muore o no")
|
|
for nm, f_, tfx in (("adattivo (A)", fn_ch, ch["tf"]), ("WF-PnL (C)", fn_wf, wch["tf"]),
|
|
("miglior costante (B)", fn_const, cch["tf"]),
|
|
("TP01 30/90/180", fixed_target, "1d")):
|
|
line = []
|
|
for fee in (0.0, 0.0005, 0.001, 0.0015):
|
|
line.append(f"{2*fee*100:.2f}%RT {A._sh(A.candidate_daily(f_, tf=tfx, fee_side=fee)):+.3f}")
|
|
tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(f_, A.get(a, tfx), a))
|
|
["turnover_per_year"] for a in ASSETS])
|
|
print(f" {nm:<22} " + " ".join(line) + f" | turnover {tt:.0f}x/anno")
|
|
|
|
hr(f"FINE — {time.time()-t0:.0f}s")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|