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>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user