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:
@@ -782,6 +782,148 @@ def _verdict(per_cell: list[dict]) -> dict:
|
||||
n_positive_cells=len(ok), n_cells=len(per_cell))
|
||||
|
||||
|
||||
def implausible_sharpe(daily, n_trades: int | None = None, n_losing_trades: int | None = None,
|
||||
sharpe_max: float = 3.0, min_loss_frac: float = 0.02,
|
||||
calmar_max: float = 20.0) -> dict:
|
||||
"""GATE: a Sharpe/track too good to be true means the RISK IS OUTSIDE THE DATASET.
|
||||
|
||||
Codifies a blind spot the project hit THREE times and each time diagnosed by hand:
|
||||
- CC01 cash-and-carry: modelled Sharpe 11-13, maxDD 0.3% — it passed EVERY gate of the
|
||||
marginal scorer. The premium was real; the model simply had no 2022, no liquidation, no
|
||||
basis blow-out. Real basis trades run Sharpe ~1-3 with abrupt tails.
|
||||
- Albimarini deep-OTM cells: 0 losses in 142 trades.
|
||||
- half of the 288-structure VRP grid: same 0-loss signature.
|
||||
|
||||
The point is NOT that a high Sharpe is impossible. It is that a track with no left tail is
|
||||
evidence about the SAMPLE, not about the strategy: the loss mode exists and did not occur.
|
||||
A flagged candidate is at best STAT-MODE — never a deploy — until the missing tail is priced.
|
||||
|
||||
`n_trades` / `n_losing_trades` enable the RULE OF THREE: observing 0 losses in N trades is
|
||||
consistent with a true loss rate up to ~3/N. On a payoff that loses many multiples of the
|
||||
premium (deep-OTM short options) that residual rate is enough to flip expectancy — which is
|
||||
exactly why "0/142" must not read as "riskless".
|
||||
|
||||
Returns dict(implausible: bool, reasons: [...], ...). ANY trigger sets implausible=True.
|
||||
"""
|
||||
s = pd.Series(daily)
|
||||
if not isinstance(s.index, pd.DatetimeIndex):
|
||||
raise TypeError("implausible_sharpe vuole una serie a indice temporale "
|
||||
"(altrimenti l'annualizzazione e' arbitraria)")
|
||||
s = _to_daily(s) # normalizza a giornaliero: _sh annualizza a 365.25
|
||||
r = np.asarray(s.dropna().values, float)
|
||||
out = dict(implausible=False, reasons=[], n_obs=int(len(r)))
|
||||
if len(r) < 30:
|
||||
out["reasons"].append(f"campione troppo corto per giudicare (n={len(r)})")
|
||||
return out
|
||||
sh, dd = _sh(s), _dd_ret(s)
|
||||
ann = float(np.mean(r) * 365.25)
|
||||
# ⚠️ la frazione di perdite si misura sulle barre ATTIVE, non su tutte. Uno sleeve che sta
|
||||
# flat la maggior parte del tempo (VRP01 e' settimanale su griglia giornaliera: 94% di zeri;
|
||||
# SKH01 e' flat l'88% dei giorni) farebbe scattare "coda sinistra assente" per COSTRUZIONE.
|
||||
# E' lo stesso errore che il monitor DVOLSPREAD evita contando in barre attive: qui l'avevo
|
||||
# ripetuto, e il gate segnalava VRP01 (perdite 0.96% su tutte -> 16.5% sulle attive).
|
||||
act = r[r != 0.0]
|
||||
loss_frac = float((act < 0).mean()) if len(act) else float("nan")
|
||||
calmar = ann / dd if dd > 1e-9 else float("inf")
|
||||
out.update(sharpe=sh, maxdd=dd, ann_return=ann, loss_frac=loss_frac, calmar=calmar,
|
||||
n_active=int(len(act)), active_frac=float(len(act) / len(r)))
|
||||
|
||||
if len(act) < 30:
|
||||
out["reasons"].append(
|
||||
f"solo {len(act)} barre ATTIVE: la coda non e' misurabile, non 'assente' "
|
||||
f"(giudizio sospeso, non promozione)")
|
||||
out["implausible"] = True
|
||||
return out
|
||||
|
||||
if sh > sharpe_max:
|
||||
out["reasons"].append(
|
||||
f"Sharpe {sh:.2f} > {sharpe_max} — nessuna strategia eseguibile lo sostiene su anni; "
|
||||
f"il candidato piu' probabile e' un rischio non nel campione")
|
||||
if loss_frac < min_loss_frac:
|
||||
out["reasons"].append(
|
||||
f"solo il {loss_frac*100:.2f}% delle {len(act)} barre ATTIVE in perdita "
|
||||
f"(<{min_loss_frac*100:.0f}%) — coda sinistra assente: e' una proprieta' del "
|
||||
f"CAMPIONE, non della strategia")
|
||||
if np.isfinite(calmar) and calmar > calmar_max:
|
||||
out["reasons"].append(
|
||||
f"Calmar {calmar:.1f} > {calmar_max} (ret {ann*100:.1f}%/a su maxDD {dd*100:.2f}%) — "
|
||||
f"il drawdown misurato non contiene il modo di perdita")
|
||||
elif not np.isfinite(calmar):
|
||||
out["reasons"].append("maxDD ~0: il modo di perdita non si e' MAI manifestato nel campione")
|
||||
|
||||
if n_trades and n_trades > 0 and n_losing_trades is not None and n_losing_trades == 0:
|
||||
ub = 3.0 / n_trades # regola del tre
|
||||
out["loss_rate_ub95"] = float(ub)
|
||||
out["reasons"].append(
|
||||
f"0 perdite su {n_trades} trade: la regola del tre lascia un tasso di perdita VERO "
|
||||
f"fino a {ub*100:.2f}% — su un payoff asimmetrico basta a ribaltare l'expectancy")
|
||||
|
||||
out["implausible"] = bool(out["reasons"])
|
||||
return out
|
||||
|
||||
|
||||
def anchor_luck_band(fn_by_offset, offsets, canonical=0, metric=_sh,
|
||||
min_frac_positive: float = 0.5) -> dict:
|
||||
"""GATE: quanta parte del titolo e' FORTUNA DELL'ANCORA (candidato dichiarato il 02/07).
|
||||
|
||||
Il timing-luck d'ancora e' multiple-testing che il deflated-Sharpe NON conta: la griglia di
|
||||
ancore non e' una griglia di PARAMETRI, quindi nessuno la dichiara come trial, ma scegliere
|
||||
(anche implicitamente) l'ancora 00:00 e' un max-of-k. Misurato su 4/4 sleeve ancorati:
|
||||
TP01 hold-out 0.31 = migliore delle 24; SKH01 minHold +1.26 = 93-98° pctl dei 23 offset;
|
||||
XS01 fase 0 al 15° pctl di DD; VRP01 e' l'unico SENZA firma (la sua fase e' la PEGGIORE).
|
||||
|
||||
`fn_by_offset(off)` -> serie di rendimenti (giornalieri o piu' fini). `metric` -> scalare.
|
||||
La stima ONESTA e' la **mediana della banda**, non il valore canonico.
|
||||
gate_pass = la metrica e' positiva alla mediana E in almeno `min_frac_positive` delle ancore
|
||||
(necessario, non sufficiente: dice che il segno non dipende dall'ancora).
|
||||
"""
|
||||
vals = {}
|
||||
for o in offsets:
|
||||
try:
|
||||
vals[o] = float(metric(fn_by_offset(o)))
|
||||
except Exception: # un'ancora rotta non deve uccidere
|
||||
continue
|
||||
if len(vals) < 3:
|
||||
return dict(gate_pass=False, reason=f"solo {len(vals)} ancore valutabili")
|
||||
arr = np.array(sorted(vals.values()), float)
|
||||
can = vals.get(canonical, float("nan"))
|
||||
pctl = float((arr < can).mean()) if np.isfinite(can) else float("nan")
|
||||
med = float(np.median(arr))
|
||||
frac_pos = float((arr > 0).mean())
|
||||
return dict(gate_pass=bool(med > 0 and frac_pos >= min_frac_positive),
|
||||
canonical=can, canonical_pctl=pctl, median=med,
|
||||
lo=float(arr.min()), hi=float(arr.max()),
|
||||
frac_positive=frac_pos, n_anchors=len(vals),
|
||||
luck=float(can - med) if np.isfinite(can) else float("nan"),
|
||||
per_offset=vals)
|
||||
|
||||
|
||||
def anchor_luck_delta(fn_a_by_offset, fn_b_by_offset, offsets, metric=_sh) -> dict:
|
||||
"""Confronto di DUE varianti sulla banda d'ancora: **mediana delle DIFFERENZE APPAIATE**.
|
||||
|
||||
Errore catturato in sessione il 26/07 e qui codificato: confrontare `mediana(A) - mediana(B)`
|
||||
mette a confronto ancore DIVERSE (la mediana di A e quella di B cadono su offset diversi) e
|
||||
puo' RIBALTARE il verdetto — successe misurando il recupero on-book di SKH01. Gli offset sono
|
||||
coppie: la statistica corretta e' la mediana di (A_o - B_o).
|
||||
|
||||
Corollario della lezione del 26/07: *se si de-lucka una strategia va de-luckato anche il suo
|
||||
DEGRADO* — ogni Δ fra due varianti misurato a un'ancora sola eredita la fortuna di quell'ancora.
|
||||
"""
|
||||
d = {}
|
||||
for o in offsets:
|
||||
try:
|
||||
d[o] = float(metric(fn_a_by_offset(o))) - float(metric(fn_b_by_offset(o)))
|
||||
except Exception:
|
||||
continue
|
||||
if len(d) < 3:
|
||||
return dict(gate_pass=False, reason=f"solo {len(d)} ancore appaiate")
|
||||
arr = np.array(list(d.values()), float)
|
||||
return dict(gate_pass=bool(np.median(arr) > 0 and (arr > 0).mean() >= 0.5),
|
||||
median_paired=float(np.median(arr)), mean_paired=float(np.mean(arr)),
|
||||
lo=float(arr.min()), hi=float(arr.max()),
|
||||
n_positive=int((arr > 0).sum()), n_anchors=len(d), per_offset=d)
|
||||
|
||||
|
||||
def study_weights(name: str, target_fn, tfs=("1d", "12h"),
|
||||
assets=CERTIFIED, fee_sweep=FEE_SWEEP) -> dict:
|
||||
"""Run a CONTINUOUS-position hypothesis on each (asset,tf), report robustness.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""I due gate MAI COSTRUITI, ora codificati e applicati RETROATTIVAMENTE (ondata 2026-07-26-bis, T2).
|
||||
|
||||
Debito dichiarato dal progetto e mai saldato:
|
||||
|
||||
`implausible_sharpe` — raccomandato il 26/06 (CC01: Sharpe modellato 11-13, maxDD 0.3%, passa
|
||||
OGNI gate del marginal scorer -> "punto cieco") e RI-raccomandato "con piu' forza" il 02/07
|
||||
(celle Albimarini deep-OTM: 0 perdite su 142 trade; meta' della griglia VRP a 288 strutture con
|
||||
la stessa firma). Tre occorrenze, tre diagnosi a mano, zero codice.
|
||||
|
||||
`anchor_luck_band` — dichiarato "candidato gate futuro" il 02/07, dopo che il timing-luck
|
||||
d'ancora era stato trovato su 4/4 sleeve ancorati. Motivo per cui serve un gate DEDICATO: la
|
||||
griglia di ancore non e' una griglia di parametri, quindi **il deflated-Sharpe non la conta**.
|
||||
|
||||
Questo script (a) prova che i gate FUNZIONANO su controlli positivi noti, poi (b) li passa su
|
||||
tutto cio' che oggi e' nel book o in forward-monitor. Un gate che non segnala nulla non e' una
|
||||
buona notizia finche' non si e' dimostrato che sa segnalare.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
for p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt"):
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from src.portfolio import sleeves as sl # noqa: E402
|
||||
|
||||
|
||||
def line(t: str) -> None:
|
||||
print("\n" + "-" * 100 + f"\n {t}\n" + "-" * 100)
|
||||
|
||||
|
||||
def show(name: str, rep: dict) -> None:
|
||||
flag = "*** IMPLAUSIBILE ***" if rep.get("implausible") else "ok"
|
||||
if "sharpe" in rep:
|
||||
print(f" {name:<16} Sh {rep['sharpe']:>6.2f} DD {rep['maxdd']*100:>5.2f}% "
|
||||
f"perdite/attive {rep['loss_frac']*100:>5.1f}% ({rep['n_active']} att.) Calmar {rep['calmar']:>7.2f} {flag}")
|
||||
else:
|
||||
print(f" {name:<16} {rep.get('reasons')} {flag}")
|
||||
for r in rep.get("reasons", []):
|
||||
print(f" - {r}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 100)
|
||||
print(" T2 — `implausible_sharpe` e `anchor_luck_band`: codificati e applicati retroattivamente")
|
||||
print("=" * 100)
|
||||
|
||||
# ------------------------------------------------------------------ controlli positivi
|
||||
line("0. CONTROLLI POSITIVI — il gate DEVE segnalare questi, sennò e' rotto")
|
||||
idx = pd.date_range("2020-01-01", periods=900, freq="1D", tz="UTC")
|
||||
rng = np.random.default_rng(726)
|
||||
|
||||
# (a) firma CC01: cashflow liscio, quasi nessuna perdita, DD ~0
|
||||
cc = pd.Series(0.0006 + rng.normal(0, 0.00012, len(idx)), index=idx)
|
||||
show("CC01-like", al.implausible_sharpe(cc))
|
||||
|
||||
# (b) firma Albimarini deep-OTM: 0 perdite su 142 trade
|
||||
alb = pd.Series(np.abs(rng.normal(0.0008, 0.0003, len(idx))), index=idx)
|
||||
show("deepOTM-like", al.implausible_sharpe(alb, n_trades=142, n_losing_trades=0))
|
||||
|
||||
# (c) controllo NEGATIVO: rumore normale a Sharpe ~1 non deve essere segnalato
|
||||
ok = pd.Series(rng.normal(0.0006, 0.011, len(idx)), index=idx)
|
||||
show("rumore Sh~1", al.implausible_sharpe(ok))
|
||||
|
||||
# ------------------------------------------------------------------ book + monitor
|
||||
line("1. `implausible_sharpe` su TUTTO cio' che e' nel book o in forward-monitor")
|
||||
series: dict[str, pd.Series] = {}
|
||||
for nm, fn in (("TP01", sl._tp01_returns), ("XS01", sl._xsec_returns),
|
||||
("VRP01", sl._vrp_combo_returns), ("SKH01", sl._skyhook_returns),
|
||||
("GTAA01", sl._gtaa_daily_returns)):
|
||||
try:
|
||||
series[nm] = fn()
|
||||
except Exception as e: # uno sleeve rotto non ferma l'audit
|
||||
print(f" {nm:<16} non valutabile: {type(e).__name__}: {e}")
|
||||
for nm, s in series.items():
|
||||
show(nm, al.implausible_sharpe(s))
|
||||
|
||||
# candidati in forward-monitor, ricostruiti dai rispettivi script di scoperta
|
||||
line("2. `implausible_sharpe` sui CANDIDATI in forward-monitor")
|
||||
try:
|
||||
import r0725_statarb_basket_gate as xg # XSR01 = versione DEMEANATA
|
||||
P, S = xg.pair_frames()
|
||||
show("XSR01", al.implausible_sharpe(xg.basket_from_positions(P, S, demean=True)))
|
||||
except Exception as e:
|
||||
print(f" XSR01 non ricostruibile qui: {type(e).__name__}: {e}")
|
||||
try:
|
||||
import r0726_dvolspread_gate as dg
|
||||
# config ONESTA congelata nel monitor (`paper_dvolspread.FROZEN`), non la cella pubblicata
|
||||
show("DVOLSPREAD", al.implausible_sharpe(
|
||||
dg.daily_returns(zwin=180, tanh_k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60)))
|
||||
except Exception as e:
|
||||
print(f" DVOLSPREAD non ricostruibile qui: {type(e).__name__}: {e}")
|
||||
|
||||
# ------------------------------------------------------------------ anchor band
|
||||
line("3. `anchor_luck_band` — ri-verifica del finding 02/07 col gate ORA CODIFICATO")
|
||||
print(" TP01 sulle 24 ancore orarie (metrica: Sharpe hold-out, il numero che il 02/07 era")
|
||||
print(" risultato il MIGLIORE delle 24). Se il gate e' giusto deve ritrovarlo da solo.")
|
||||
import r0726_tp01_partial_day as pd_mod
|
||||
|
||||
cache: dict[int, dict] = {}
|
||||
|
||||
def model_at(off: int) -> pd.Series:
|
||||
if off not in cache:
|
||||
cache[off] = pd_mod.evaluate(off)
|
||||
s = cache[off]["MODEL"]
|
||||
return s[s.index >= pd_mod.HOLDOUT]
|
||||
|
||||
band = al.anchor_luck_band(model_at, range(24), canonical=0,
|
||||
metric=lambda s: pd_mod.sharpe(s))
|
||||
print(f"\n canonica (00:00) {band['canonical']:+.3f} | pctl nella banda "
|
||||
f"{band['canonical_pctl']*100:.0f}° | mediana {band['median']:+.3f} "
|
||||
f"| banda [{band['lo']:+.3f},{band['hi']:+.3f}]")
|
||||
print(f" positiva in {band['frac_positive']*100:.0f}% delle ancore | "
|
||||
f"fortuna = canonica - mediana = {band['luck']:+.3f} | gate_pass={band['gate_pass']}")
|
||||
|
||||
print("\n E il confronto APPAIATO fra due varianti (live con barra parziale vs modello):")
|
||||
delta = al.anchor_luck_delta(
|
||||
lambda o: cache.setdefault(o, pd_mod.evaluate(o))["LIVE"],
|
||||
lambda o: cache.setdefault(o, pd_mod.evaluate(o))["MODEL"],
|
||||
range(24), metric=lambda s: pd_mod.sharpe(s))
|
||||
print(f" mediana delle DIFFERENZE appaiate {delta['median_paired']:+.3f} "
|
||||
f"[{delta['lo']:+.3f},{delta['hi']:+.3f}] positiva in "
|
||||
f"{delta['n_positive']}/{delta['n_anchors']} gate_pass={delta['gate_pass']}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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