Files
PythagorasGoal/scripts/research/r0726_tp01_partial_day.py
T
Adriano Dal Pastro 37be1df838 research: ondata 26/07-bis — TP01 su barra parziale + i 2 gate mai costruiti
0 sleeve nuovi. Book, pesi, cron, config INVARIATI.

T1 — anche TP01 legge una barra giornaliera PARZIALE nel live, ma non conta.
Stesso fatto strutturale di SKH01: resample_tf non scarta il giorno in corso e
current_target prende [-1]; il feed si ricostruisce alle 00:30 UTC, quindi per
tutta la giornata il book vede oggi come 1 barra oraria su 24 (verificato).
Il docstring "ultima barra CHIUSA" era falso: corretto.

Tre path a un grado di liberta' per volta, 24 ancore, differenze appaiate:
  barra parziale   ΔFULL -0.031 (pos 7/24)  ΔHOLD +0.118 (pos 19/24)
  ritardo 1h       ΔFULL -0.004 (pos 11/24 = moneta)
  leva LIVE/MODEL  1.004
-> trascurabile, nessun cambio al live.

All'ancora canonica sembra peggio del vero: a offset 0 la parziale costa -0.230
di hold-out, che e' il MINIMO della banda (mediana +0.118). Speculare alla
lezione del 26/07: li' l'ancora canonica nascondeva un vantaggio, qui inventa
un danno.

REGOLA: la parzialita' dell'ultima barra conta in proporzione a quanto il
segnale pesa la barra piu' recente. Donchian breakout su 230m (la barra corrente
E' il segnale) -> +0.38; TSMOM 30/90/180g -> ±0.03. Non si trasferisce.

T2 — implausible_sharpe e anchor_luck_band codificati in altlib (debito
raccomandato 3 volte e mai scritto), piu' anchor_luck_delta che codifica
l'errore di stamattina (mediana delle differenze appaiate, non differenza
delle mediane).

Il gate ha segnalato VRP01 e il difetto era MIO: perdite contate su tutte le
barre, ma VRP01 e' settimanale su griglia giornaliera (94.2% di zeri) -> "0.96%,
coda assente" su uno sleeve in produzione. Sulle barre ATTIVE e' 16.5%, la forma
giusta di un credit spread a rischio definito. La lezione era gia' cablata il
giorno prima nel monitor DVOLSPREAD ("barre attive, non giorni di calendario").

Applicazione retroattiva 7/7 tutti ok, con controlli positivi obbligatori
superati (firme CC01 e deep-OTM segnalate, rumore Sh 0.62 no).

Replica indipendente del finding d'ancora del 02/07: il gate applicato alla
cieca a TP01 ritrova canonica +0.237 = 92 pctl delle 24, mediana onesta +0.056,
fortuna +0.182 — contro mediana 0.04 misurata il 02/07 con implementazione
separata. L'hold-out onesto di TP01 e' ~+0.05, non 0.31.

NON fatto: il book ricalcolato sul path live, bloccato da incompatibilita' di
lenti (simulatore per-trade vs sleeve vol-targeted). Follow-up dichiarato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:36:40 +00:00

255 lines
11 KiB
Python

"""TP01 nel LIVE: la barra GIORNALIERA e' PARZIALE. Quanto conta? (ondata 2026-07-26-bis, T1)
CONTESTO. Il 26/07 ho misurato che il live di SKH01 valuta il segnale su una barra 230m parziale
(uscite e ingressi) e che il backtest, modellandolo a chiusura di bin, lo sottostimava su entrambi
i lati. Questo script chiede la STESSA cosa allo sleeve che pesa il **75% del book Deribit**.
IL FATTO (verificato, non dedotto):
`resample_tf(..., label="left", closed="left")` NON scarta il giorno in corso, e
`TrendPortfolio.current_target` prende `target_series(df)[-1]` — cioe' **l'ultima barra, che e'
parziale**. Il feed certificato viene ricostruito da `cron_daily` alle 00:30 UTC, quindi per tutta
la giornata il live vede il giorno corrente come una barra da **1 ora su 24** (misurato oggi:
`barre1h=1/24`).
Il docstring di `current_target` dice "ultima barra CHIUSA": e' un'assunzione sul chiamante, e nel
live NON e' verificata.
DUE CANALI, che vanno separati perche' hanno segno atteso opposto:
(a) ESECUZIONE RITARDATA — il modello ribilancia al confine 00:00, il live al primo giro di cron
utile dopo il rebuild (~01:00). Un'ora di ritardo su uno sleeve daily: atteso ~rumore.
(b) BARRA PARZIALE — il target del live usa c[t] = prezzo delle 01:00 al posto della chiusura
piena del giorno, e r[t] = un rendimento di UN'ORA contato come rendimento GIORNALIERO dentro
la finestra di vol a 30 barre. La vol realizzata esce **sottostimata** -> `target_vol/vol`
esce **sovrastimata** -> il live **sovra-leva** in modo sistematico. Direzione attesa: negativa.
TRE PATH, che isolano i due canali (differenza fra due path = un solo grado di liberta'):
MODEL tgt_full[t-1] tenuto in [t 00:00, t+1 00:00) = il backtest canonico
CLOSED@+1h tgt_full[t-1] tenuto in [t 01:00, t+1 01:00) = scarta la parziale, stessa ora del live
LIVE tgt_live[t] tenuto in [t 01:00, t+1 01:00) = cio' che gira oggi
LIVE - CLOSED = effetto puro della BARRA PARZIALE
CLOSED - MODEL = effetto puro del RITARDO d'esecuzione
BANDA D'ANCORA. TP01 e' gia' noto per anchor timing-luck (02/07: l'ancora 00:00 e' la migliore
delle 24). Lezione codificata stamattina: *se si de-lucka una strategia va de-luckato anche il suo
degrado*. Quindi ogni Δ e' riportato sulle 24 ancore, con la **mediana delle DIFFERENZE APPAIATE**
(non la differenza delle mediane: gli offset sono coppie).
Tutto su griglia ORARIA: i tre path hanno confini diversi e solo l'orario li rende confrontabili.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
ASSETS = ("BTC", "ETH")
HOLDOUT = "2025-01-01"
MS_H = 3_600_000
MS_D = 86_400_000
FEE_SIDE = float(CANONICAL.get("fee_side", 0.0005))
EXEC_LAG_H = 1 # il primo giro di cron_book utile dopo il rebuild delle 00:30
# --------------------------------------------------------------------------- dati
def hourly(asset: str) -> tuple[np.ndarray, np.ndarray]:
df = load_data(asset, "1h")
return (df["timestamp"].values.astype(np.int64),
df["close"].values.astype(float))
def daily_from_hourly(ts: np.ndarray, c: np.ndarray, off_h: int) -> dict:
"""Barre giornaliere ancorate a `off_h` (UTC), piu' il prezzo alla PRIMA ora del giorno.
Ritorna: day_start (ms), close pieno, close parziale (dopo 1 ora), n. di barre 1h per giorno.
"""
day = (ts - off_h * MS_H) // MS_D
s = pd.DataFrame({"day": day, "ts": ts, "c": c})
g = s.groupby("day", sort=True)
full = g["c"].last().values
first = g["c"].first().values # close della PRIMA barra 1h -> la "parziale" a +1h
n = g["c"].size().values
start = (g["ts"].min().values // MS_H) * MS_H
return dict(start=start, full=full, partial=first, n=n)
# --------------------------------------------------------------------------- target
def targets_full(cf: np.ndarray, tp: TrendPortfolio) -> np.ndarray:
"""target_series su barre giornaliere PIENE (bpd=1 -> orizzonti in giorni)."""
d = pd.DataFrame({"close": cf,
"datetime": pd.to_datetime(np.arange(len(cf)) * MS_D, unit="ms", utc=True)})
return tp.target_series(d)
def targets_live(cf: np.ndarray, cp: np.ndarray, tp: TrendPortfolio) -> np.ndarray:
"""Ricostruisce cio' che il live calcola: giorni 0..t-1 PIENI + giorno t PARZIALE, indice [-1].
Riproduce a mano la formula di `target_series` sostituendo il SOLO ultimo elemento — che e'
esattamente cio' che cambia fra la vista del live e quella del backtest.
"""
n = len(cf)
vw = tp.vol_win_days # bpd=1 su barre giornaliere
hz = tuple(tp.horizons_days)
bpy = 365.25
r_full = np.zeros(n)
r_full[1:] = cf[1:] / cf[:-1] - 1.0
out = np.zeros(n)
for t in range(n):
# direzione: sign(c[t]/c[t-h]) con c[t] = PARZIALE
acc = cnt = 0.0
for h in hz:
if t - h >= 0:
acc += np.sign(cp[t] / cf[t - h] - 1.0)
cnt += 1
if cnt == 0:
continue
direction = acc / cnt
if tp.long_only:
direction = max(direction, 0.0)
# vol: finestra a vw barre che finisce in t, con r[t] = rendimento di UN'ORA
a = max(0, t - vw + 1)
w = r_full[a:t + 1].copy()
if t >= 1:
w[-1] = cp[t] / cf[t - 1] - 1.0
if len(w) < max(1, vw // 2):
continue
sd = float(np.std(w, ddof=1)) if len(w) > 1 else 0.0
vol = sd * np.sqrt(bpy)
if not np.isfinite(vol) or vol <= 0:
continue
out[t] = float(np.clip(direction * (tp.target_vol / vol), -tp.leverage, tp.leverage))
return out
# --------------------------------------------------------------------------- path orari
def hourly_position(ts: np.ndarray, dstart: np.ndarray, tgt: np.ndarray,
shift_h: int) -> np.ndarray:
"""Posizione oraria: `tgt[k]` in vigore da (dstart[k] + shift_h ore) fino al successivo."""
edges = dstart + shift_h * MS_H
j = np.searchsorted(edges, ts, side="right") - 1
pos = np.where(j >= 0, tgt[np.clip(j, 0, len(tgt) - 1)], 0.0)
return pos
def path_returns(ts: np.ndarray, c: np.ndarray, pos: np.ndarray) -> pd.Series:
"""Rendimenti orari netti fee sul turnover, indicizzati per timestamp."""
r = np.zeros(len(c))
r[1:] = c[1:] / c[:-1] - 1.0
held = np.zeros(len(pos))
held[1:] = pos[:-1] # tenuta durante l'ora i = decisa a i-1
turn = np.abs(np.diff(held, prepend=0.0))
net = held * r - FEE_SIDE * turn
return pd.Series(net, index=pd.to_datetime(ts, unit="ms", utc=True))
def sharpe(s: pd.Series) -> float:
x = s.values[np.isfinite(s.values)]
if len(x) < 50 or np.std(x) == 0:
return 0.0
return float(np.mean(x) / np.std(x) * np.sqrt(24 * 365.25))
def maxdd(s: pd.Series) -> float:
eq = np.cumprod(1.0 + np.clip(s.values, -0.99, None))
return float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
# --------------------------------------------------------------------------- valutazione
def evaluate(off_h: int) -> dict:
"""I tre path per una data ancora, combinati 50/50 BTC+ETH come fa lo sleeve."""
tp = TrendPortfolio(**CANONICAL)
legs: dict[str, list] = {"MODEL": [], "CLOSED": [], "LIVE": []}
lev: dict[str, list] = {"MODEL": [], "LIVE": []}
for a in ASSETS:
ts, c = hourly(a)
d = daily_from_hourly(ts, c, off_h)
cf, cp, dstart = d["full"], d["partial"], d["start"]
tf = targets_full(cf, tp)
tl = targets_live(cf, cp, tp)
# MODEL: tgt[t-1] tenuto dal confine del giorno t -> shift di 1 giorno = tgt spostato avanti
tf_prev = np.concatenate([[0.0], tf[:-1]])
legs["MODEL"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, 0)))
legs["CLOSED"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, EXEC_LAG_H)))
legs["LIVE"].append(path_returns(ts, c, hourly_position(ts, dstart, tl, EXEC_LAG_H)))
lev["MODEL"].append(tf_prev)
lev["LIVE"].append(tl)
out = {}
for k, v in legs.items():
J = pd.concat(v, axis=1, join="inner").fillna(0.0)
out[k] = 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1]
# diagnostica di leva: quanto sovra-leva il live rispetto al modello?
ml = np.concatenate(lev["MODEL"]); ll = np.concatenate(lev["LIVE"])
m = (ml > 0) & (ll > 0)
out["_lev_ratio"] = float(np.median(ll[m] / ml[m])) if m.any() else float("nan")
return out
def report(off_h: int, res: dict) -> dict:
row = dict(off=off_h, lev=res["_lev_ratio"])
for k in ("MODEL", "CLOSED", "LIVE"):
s = res[k]
row[f"{k}_full"] = sharpe(s)
row[f"{k}_hold"] = sharpe(s[s.index >= HOLDOUT])
row[f"{k}_dd"] = maxdd(s)
return row
def main() -> None:
print("=" * 100)
print(" TP01 nel LIVE — la barra giornaliera e' PARZIALE (1h su 24). Quanto conta?")
print("=" * 100)
print("\n MODEL = tgt[t-1] dal confine 00:00 (backtest canonico)")
print(" CLOSED@+1h = tgt[t-1] dal confine +1h (scarta la parziale, ora del live)")
print(" LIVE = tgt[t] da barra PARZIALE, +1h (cio' che gira oggi)")
print(" LIVE-CLOSED = effetto BARRA PARZIALE | CLOSED-MODEL = effetto RITARDO\n")
rows = [report(o, evaluate(o)) for o in range(24)]
df = pd.DataFrame(rows)
print("-" * 100)
print(" ancora canonica (offset 0) — quella su cui sono calcolati TUTTI i numeri di TP01")
print("-" * 100)
r0 = df[df.off == 0].iloc[0]
print(f" {'path':<12}{'FULL':>8}{'HOLD':>8}{'maxDD':>9}")
for k in ("MODEL", "CLOSED", "LIVE"):
print(f" {k:<12}{r0[f'{k}_full']:>8.2f}{r0[f'{k}_hold']:>8.2f}{r0[f'{k}_dd']*100:>8.1f}%")
print(f"\n leva del LIVE / leva del MODEL (mediana sui giorni entrambi investiti): "
f"{r0['lev']:.3f}")
print("\n" + "-" * 100)
print(" BANDA DELLE 24 ANCORE — mediana delle DIFFERENZE APPAIATE (non differenza di mediane)")
print("-" * 100)
for lab, a, b in (("BARRA PARZIALE (LIVE - CLOSED)", "LIVE", "CLOSED"),
("RITARDO 1h (CLOSED - MODEL)", "CLOSED", "MODEL"),
("TOTALE (LIVE - MODEL)", "LIVE", "MODEL")):
for w in ("full", "hold"):
d = df[f"{a}_{w}"] - df[f"{b}_{w}"]
print(f" {lab:<34} {w.upper():<5} mediana {d.median():+.3f} "
f"[{d.min():+.3f},{d.max():+.3f}] positivo in {int((d > 0).sum())}/24")
print(f"\n leva LIVE/MODEL sulla banda: mediana {df['lev'].median():.3f} "
f"[{df['lev'].min():.3f},{df['lev'].max():.3f}]")
print("\n" + "-" * 100)
print(" per-ancora (FULL)")
print("-" * 100)
print(f" {'off':>4}{'MODEL':>8}{'CLOSED':>8}{'LIVE':>8}{'ΔparzialeF':>12}{'ΔparzialeH':>12}{'lev':>7}")
for _, r in df.iterrows():
print(f" {int(r.off):>4}{r.MODEL_full:>8.2f}{r.CLOSED_full:>8.2f}{r.LIVE_full:>8.2f}"
f"{r.LIVE_full - r.CLOSED_full:>+12.3f}{r.LIVE_hold - r.CLOSED_hold:>+12.3f}"
f"{r.lev:>7.3f}")
print()
if __name__ == "__main__":
main()