Files
PythagorasGoal/scripts/research/r0725_prop_coupled.py
T
Adriano Dal Pastro 4cb82dc474 research(prop): lente wick ACCOPPIATA — chiude il follow-up dichiarato del 25/07
La scala di conti funded era giudicata con due lenti a un ordine di grandezza di
distanza su P(>=50 EUR/g): 20.7% close-only vs 1.6-2.5% wick indipendente. Il
difetto era dichiarato (gap estratto indipendente dal rendimento del giorno) ma
il recon accoppiato esisteva solo per il book 75/25.

Qui si generalizza: sleeve TP01/SKH01 separati a risoluzione oraria (minimo
intraday esatto per qualsiasi vettore di pesi) + XS01 accoppiato dagli OHLC
giornalieri HL (recon = sleeve ufficiale a max|delta|=0.0).

FINDING: la calibrazione del wick era giusta, l'errore era l'INDIPENDENZA. Le
marginali coincidono (p50 -0.17pp identico) ma il gap e' ~3x piu' profondo nei
giorni che finiscono BENE (-1.58pp vs -0.48pp), perche' un giorno brutto chiude
sul minimo (m==R nel 26% dei giorni). Il breach si valuta sul minimo -> la lente
indipendente raddoppia i breach da daily-loss (2.0-2.9x, misurato sui giorni
storici). close-only non e' conservativa ma CIECA: 0.00% di breach ovunque.

Verifiche: (1) 1h vs 5m identici (p99 -3.06 vs -3.14pp) -> il caveat di
risoluzione del 24/07 e' trascurabile; (2) riconciliazione col 24/07 RISOLTA
(63.2% vs 58% sulla stessa finestra: non era la finestra, era la lente);
(3) bound severo su XS01 non ribalta config B.

Decisioni: funded 0.75x (non 0.50x — massimizza il payout a 3 anni, $2674 vs
$1230); scala P(>=50/g) ~6% con P(zero) 52-65%; ordine fra politiche invariato
a tutte e 3 le lenti. Book/pesi/cron INVARIATI.

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

694 lines
34 KiB
Python

"""r0725_prop_coupled — CHIUDE il follow-up dichiarato: la lente WICK ACCOPPIATA (2026-07-25).
`r0725_prop_ladder.py` ha giudicato la scala di conti funded con DUE lenti che differiscono di
un ordine di grandezza su P(>=50 EUR/g): **20.7% close-only** (tetto) vs **1.6-2.5% wick
indipendente** (pavimento). La banda e' cosi' larga da non decidere nulla: e' la differenza fra
"scommessa a coda destra che vale 600 euro" e "non vale la pena".
La ragione della larghezza e' un DIFETTO DICHIARATO del pavimento: il gap intraday era estratto
**lognormale INDIPENDENTE dal rendimento del giorno**. Ma i wick profondi NON sono indipendenti:
stanno sulle giornate brutte. Estrarli indipendenti aggiunge finti tuffi anche nelle giornate
buone -> breach spuri -> P(pass) e P(vivo) troppo basse. Il modo giusto e' il bootstrap delle
**TUPLE (ritorno, gap)** accoppiate, che r0724_goal50_intraday_mc.py fa — ma solo per il book
Deribit 75/25 (TP01+SKH01), non per XS01 ne' a pesi arbitrari. Qui si generalizza.
COSA FA QUESTO SCRIPT
1) **Recon per-sleeve** a risoluzione oraria: (ret, wick) unitari di TP01 e SKH01 separati,
cosi' il minimo intraday si puo' calcolare esattamente per QUALSIASI vettore di pesi
(non solo 75/25). Riusa la macchina di r0724 (exit-al-livello, SL prioritario, fee).
2) **Lente accoppiata per XS01**, che su Hyperliquid e' 1d nativo: l'escursione intraday si
ricostruisce dagli OHLC GIORNALIERI dei 19 alt con ordinamento condiviso (tutte le gambe
toccano prima il minimo, o prima il massimo — le alt co-muovono), prendendo l'ordine
AVVERSO. Non e' path-exact, ma e' **accoppiato al giorno vero**, che e' il punto.
3) **Prova empirica del difetto**: gap per decile di rendimento + conteggio dei breach sui
giorni STORICI (nessun bootstrap) sotto le tre lenti.
4) Le 4 politiche di scala e la decisione HyroTrader config A vs B, girate a **TRE lenti**
(close-only / indipendente / accoppiata) sulla stessa macchina di simulazione.
5) Tre VERIFICHE, perche' un "nessuna differenza" va provato: risoluzione 1h vs 5m,
riconciliazione col numero indipendente del 24/07, bound severo su XS01.
RISULTATO IN UNA RIGA: la CALIBRAZIONE del wick del 25/07 era giusta (p50 -0.17pp identico),
l'errore era l'INDIPENDENZA. L'escursione intraday e' ~3x piu' profonda nei giorni che finiscono
BENE (recuperi a V) che in quelli che finiscono male (che chiudono sul minimo: m==R nel 26% dei
giorni). Il breach si valuta sul minimo -> estrarre il gap indipendente carica i giorni brutti
con la coda che appartiene ai giorni buoni, e RADDOPPIA i breach da daily-loss (2.0-2.9x).
CONVENZIONI DICHIARATE (le stesse del 24/07, piu' una nuova)
* il minimo di un book multi-sleeve e' preso come **somma dei minimi** (worst simultaneo) fra
la parte crypto e XS01: severo, ma coerente con la convenzione gia' in uso fra TP01 e SKH01
dentro l'ora. Fra TP01 e SKH01 il minimo e' invece ESATTO (path orario condiviso).
* wick a risoluzione 1h sul crypto. ⚠️ NON e' un tetto di risoluzione: MISURATO contro i 5m
sulla gamba TP01, p99 -3.06pp (1h) vs -3.14pp (5m) -> l'ora cattura gia' il minimo del
giorno. (Il caveat "i 5m sarebbero piu' profondi" del 24/07 e' quantificato e trascurabile.)
* XS01 a risoluzione di barra giornaliera (4 checkpoint O/H/L/C, ordine condiviso avverso);
il bound opposto (ogni gamba al proprio peggio insieme) e' girato come sensitivita' e NON
ribalta nessuna conclusione.
Uso: `uv run python scripts/research/r0725_prop_coupled.py` (~2-3 min: recon SKH su 5m full)
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.data.downloader import load_data # noqa: E402
from src.portfolio.portfolio import metrics # noqa: E402
from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _HL_DIR, _xsec_returns # noqa: E402
import r0724_goal50_intraday_mc as mc # noqa: E402
import r0725_prop_ladder as pl # noqa: E402
from r0725_capcurve import EURUSD, TAX_RATE # noqa: E402
ASSETS = ("BTC", "ETH")
DELUCK = pl.DELUCK
START_EUR = pl.START_EUR
HORIZON_MONTHS = pl.HORIZON_MONTHS
MAX_CONCURRENT = pl.MAX_CONCURRENT
TARGET_EUR_DAY = pl.TARGET_EUR_DAY
SPLIT = pl.SPLIT
FIRM_DEATH_PER_YEAR = pl.FIRM_DEATH_PER_YEAR
FIRMS = pl.FIRMS
# ================================================================= 1. RECON PER-SLEEVE (1h)
@lru_cache(maxsize=1)
def _hourly_legs() -> tuple[pd.DataFrame, ...]:
"""(ret, wick) ORARI UNITARI di TP01 e SKH01 — cioe' a peso di sleeve 1.0, gia' sommati
sui due asset (ogni asset pesa 0.5 dentro lo sleeve).
r0724 cabla i pesi di book (W_TP=0.75, W_SKH=0.25) dentro il recon; qui si dividono via
per ottenere gli sleeve nudi, che si possono poi ricombinare a qualsiasi peso.
"""
tp_r = tp_w = skh_r = skh_w = None
for a in ASSETS:
df = load_data(a, "1h")
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
close = df["close"].values.astype(float)
low = df["low"].values.astype(float)
high = df["high"].values.astype(float)
prev = np.concatenate(([np.nan], close[:-1]))
r_close = close / prev - 1.0
# --- TP01: peso costante nel giorno (deciso a close[d-1]), fee alla prima ora
tpf = mc.tp01_daily_frame(a) # gia' scalato *0.5 (leg), senza W_TP
day_of = idx.floor("D")
w_tp = np.nan_to_num(tpf["w"].reindex(day_of).values)
fee_tp = np.nan_to_num(tpf["fee"].reindex(day_of).values)
first = np.concatenate(([True], day_of[1:] != day_of[:-1]))
r_wick = np.where(w_tp >= 0, low / prev - 1.0, high / prev - 1.0)
t_r = pd.Series(w_tp * r_close - np.where(first, fee_tp, 0.0), index=idx)
t_w = pd.Series(w_tp * r_wick - np.where(first, fee_tp, 0.0), index=idx)
# --- SKH01: MTM per-trade con exit AL LIVELLO (r0724 cabla w = W_SKH*0.5)
s_r_arr, s_w_arr = mc.skh_hourly_contrib(a, idx, close, low, high)
s_r = pd.Series(s_r_arr / mc.W_SKH, index=idx)
s_w = pd.Series(s_w_arr / mc.W_SKH, index=idx)
tp_r = t_r if tp_r is None else tp_r.add(t_r, fill_value=np.nan)
tp_w = t_w if tp_w is None else tp_w.add(t_w, fill_value=np.nan)
skh_r = s_r if skh_r is None else skh_r.add(s_r, fill_value=np.nan)
skh_w = s_w if skh_w is None else skh_w.add(s_w, fill_value=np.nan)
J = pd.concat({"tp_r": tp_r, "tp_w": tp_w, "skh_r": skh_r, "skh_w": skh_w},
axis=1).dropna()
return (J,)
def crypto_daily_tuples(w_tp: float, w_skh: float) -> pd.DataFrame:
"""Per giorno UTC: R (chiusura) e m (minimo intraday) del sub-book crypto a pesi arbitrari.
Il minimo fra TP01 e SKH01 e' ESATTO: si compone il path ORARIO del book e si guarda il
minimo, includendo il wick dentro ogni ora. (Il wick intra-ora dei due sleeve e' sommato:
li' resta la convenzione worst-simultaneo di r0724.)
"""
(J,) = _hourly_legs()
r_h = (w_tp * J["tp_r"] + w_skh * J["skh_r"]).values
w_h = (w_tp * J["tp_w"] + w_skh * J["skh_w"]).values
idx = J.index
days = idx.floor("D")
rows = []
for day, pos in pd.Series(np.arange(len(idx)), index=days).groupby(level=0):
sl = pos.values
r = r_h[sl]
wk = w_h[sl]
cum = np.cumprod(1.0 + r)
cum_prev = np.concatenate(([1.0], cum[:-1]))
m = min(cum.min(), (cum_prev * (1.0 + wk)).min()) - 1.0
rows.append((day, cum[-1] - 1.0, min(m, cum[-1] - 1.0)))
return pd.DataFrame(rows, columns=["day", "R", "m"]).set_index("day")
# ================================================================= 2. XS01 ACCOPPIATO (1d OHLC)
@lru_cache(maxsize=2)
def xsec_daily_tuples(wick_mode: str = "shared") -> pd.DataFrame:
"""XS01 con escursione intraday ricostruita dagli OHLC GIORNALIERI dei 19 alt.
Replica la costruzione delle posizioni di `sleeves._xsec_returns` (verificata a valle contro
lo sleeve ufficiale: `assert` sulla chiusura) e in piu', per ogni giorno, valuta il book ai
4 checkpoint O / primo estremo / secondo estremo / C, con **ordinamento CONDIVISO** fra le
gambe (le alt co-muovono) e prendendo l'**ordine avverso** fra "prima i minimi" e "prima i
massimi". Su un book market-neutral l'ordinamento condiviso si auto-compensa in larga parte
(i long perdono, gli short guadagnano): il rischio intraday vero e' la DISPERSIONE, che
questa lente cattura solo in parte -> il gap XS01 esce PICCOLO, e va letto come tale.
wick_mode='perleg' e' il BOUND SEVERO opposto: ogni gamba tocca il PROPRIO peggio nello stesso
istante (long al minimo E short al massimo). E' impossibile in pratica su 10 gambe co-moventi,
ma delimita l'errore: se la conclusione regge a entrambi gli estremi, non dipende dalla
convenzione. Serve perche' e' XS01 a reggere il vantaggio della config diversificata.
"""
o = {}; h = {}; lo = {}; c = {}
for sym in XS_UNIVERSE:
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
if not p.exists():
continue
d = pd.read_parquet(p)
i = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
o[sym] = pd.Series(d["open"].astype(float).values, index=i)
h[sym] = pd.Series(d["high"].astype(float).values, index=i)
lo[sym] = pd.Series(d["low"].astype(float).values, index=i)
c[sym] = pd.Series(d["close"].astype(float).values, index=i)
C = pd.concat(c, axis=1, join="inner").sort_index().dropna()
O = pd.concat(o, axis=1).reindex(C.index)[C.columns]
H = pd.concat(h, axis=1).reindex(C.index)[C.columns]
L = pd.concat(lo, axis=1).reindex(C.index)[C.columns]
px = C.values
n, A = px.shape
lookbacks, Hh, k, mode, tv = (XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"],
XS_CFG["mode"], XS_CFG["target_vol"])
disp_pct, minhist = XS_CFG.get("disp_pct", 0), XS_CFG.get("disp_minhist", 20)
mlb = max(lookbacks)
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
W = np.zeros((n, A)); w = np.zeros(A); disp_hist = []
for i in range(n):
if i >= mlb and i % Hh == 0:
rLs = [px[i] / px[i - Lk] - 1.0 for Lk in lookbacks]
disp_i = float(np.mean([r.std() for r in rLs]))
thr = (np.percentile(disp_hist, disp_pct)
if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf)
if disp_i >= thr:
score = np.zeros(A); cnt = 0
for rL in rLs:
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
if cnt:
score /= cnt
order = np.argsort(score)
w = np.zeros(A); low_i, hi_i = order[:k], order[-k:]
if mode == "mom":
w[hi_i] = 0.5 / k; w[low_i] = -0.5 / k
else:
w[low_i] = 0.5 / k; w[hi_i] = -0.5 / k
else:
w = np.zeros(A)
disp_hist.append(disp_i)
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum()
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
net = gross - turn * (0.001 / 2.0)
s = pd.Series(net, index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
# --- escursione intraday, gambe tenute = W[i-1], prezzo base = close[i-1]
prev_c = np.vstack([np.full((1, A), np.nan), px[:-1]])
held = np.vstack([np.zeros((1, A)), W[:-1]])
with np.errstate(invalid="ignore"):
rO = O.values / prev_c - 1.0
rH = H.values / prev_c - 1.0
rL = L.values / prev_c - 1.0
rC = px / prev_c - 1.0
vO = np.nansum(held * rO, axis=1)
vH = np.nansum(held * rH, axis=1)
vL = np.nansum(held * rL, axis=1)
vC = np.nansum(held * rC, axis=1)
# ordinamento condiviso: low-first (O -> L -> H -> C) oppure high-first (O -> H -> L -> C).
# Il book market-neutral vede i due estremi come punti, non come somma di worst per gamba.
if wick_mode == "perleg":
# bound severo: ogni gamba al proprio peggio simultaneamente
worst_leg = np.where(held >= 0, rL, rH)
m_gross = np.minimum(np.nansum(held * worst_leg, axis=1), vC)
elif wick_mode == "shared":
m_lowfirst = np.minimum.reduce([vO, vL, vH, vC])
m_highfirst = np.minimum.reduce([vO, vH, vL, vC])
m_gross = np.minimum(m_lowfirst, m_highfirst) # ordine AVVERSO
else:
raise ValueError(wick_mode)
# il costo di turnover si paga comunque; il vol-target scala tutto
m_net = (m_gross - turn * (0.001 / 2.0)) * scale
R_net = net * scale
out = pd.DataFrame({"R": R_net, "m": np.minimum(m_net, R_net)}, index=C.index)
return out
# ================================================================= 3. PANNELLO A 3 SLEEVE
def _d(s: pd.Series) -> pd.Series:
s = s.dropna().sort_index()
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
return s
_TUP_CACHE: dict = {}
def book_tuples(w_tp: float, w_skh: float, w_xs: float, start: str | None = None,
xs_mode: str = "shared") -> tuple[np.ndarray, np.ndarray, pd.DatetimeIndex]:
"""(R, gap) giornalieri di un book a pesi arbitrari. gap = minimo_intraday - chiusura (<=0).
Crypto: minimo ESATTO sul path orario condiviso di TP01+SKH01. XS01: il suo minimo si SOMMA
a quello crypto (worst simultaneo — convenzione dichiarata, severa).
La finestra si interseca con XS01 SOLO se XS01 ha peso: a peso zero il book crypto tiene
tutta la sua storia (serve per misurare la geometria dei gap del book live).
NB: niente round-trip int64 dell'indice — su indici tz-aware non-ns pandas 2.x cambia scala
silenziosamente (lezione 2026-07-01). Si cachea il DatetimeIndex cosi' com'e'.
"""
key = (float(w_tp), float(w_skh), float(w_xs), xs_mode)
if key not in _TUP_CACHE:
cry = None if (w_tp == 0.0 and w_skh == 0.0) else crypto_daily_tuples(w_tp, w_skh)
xs = xsec_daily_tuples(xs_mode)
if cry is None:
idx, R, m = xs.index, w_xs * xs["R"].values, w_xs * xs["m"].values
elif w_xs == 0.0:
idx, R, m = cry.index, cry["R"].values, cry["m"].values
else:
idx = cry.index.intersection(xs.index)
cr, xr = cry.reindex(idx), xs.reindex(idx)
R = cr["R"].values + w_xs * xr["R"].values
m = cr["m"].values + w_xs * xr["m"].values
keep = np.isfinite(R) & np.isfinite(m)
R, m, idx = R[keep], m[keep], idx[keep]
_TUP_CACHE[key] = (R, np.minimum(m, R) - R, idx)
R, G, I = _TUP_CACHE[key]
if start is not None:
k = I >= pd.Timestamp(start, tz="UTC")
return R[k], G[k], I[k]
return R.copy(), G.copy(), I
# ================================================================= 4. SIMULAZIONE (3 lenti)
def simulate(cfgs: list[tuple[float, float, float]], slots: list[int], n_paths: int, lev: float,
seed: int, lens: str, start: str, block: int = 20) -> dict:
"""Scala di conti funded. `cfgs` = vettori di peso distinti; `slots` = indice di cfg per
ciascuno dei MAX_CONCURRENT slot. Il bootstrap a blocchi usa lo STESSO indice per tutte le
configurazioni -> la correlazione fra conti e' quella REALE (e le tuple (R,gap) restano
accoppiate al loro giorno).
lens: 'close' -> breach sulla chiusura (tetto)
'indip' -> gap lognormale indipendente (il pavimento del 25/07)
'coupled'-> gap dal recon, accoppiato al giorno (la lente onesta)
"""
rng = np.random.default_rng(seed)
n_days = HORIZON_MONTHS * 30
mats = [book_tuples(*c, start=start) for c in cfgs]
L = min(len(m[0]) for m in mats)
Rm = np.stack([m[0][-L:] for m in mats], axis=1) # (giorni, cfg)
Gm = np.stack([m[1][-L:] for m in mats], axis=1)
Rm = Rm - (1.0 - DELUCK) * Rm.mean(axis=0, keepdims=True) # de-luck sul drift
n_blocks = int(np.ceil(n_days / block))
starts = rng.integers(0, L - block, size=(n_paths, n_blocks))
idx = (starts[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days]
n_slot = len(slots)
sel = np.asarray(slots)
Rs = Rm[idx][:, :, sel] * lev # (path, day, slot)
if lens == "close":
G = np.zeros_like(Rs)
elif lens == "coupled":
G = Gm[idx][:, :, sel] * lev
elif lens == "indip":
vol_k = Rs.reshape(-1, n_slot).std(axis=0) * np.sqrt(365.0)
scale = np.clip(vol_k / pl.WICK_REF_VOL, 0.0, 3.0)
G = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=Rs.shape)) * scale[None, None, :]
else:
raise ValueError(lens)
cash = np.full(n_paths, START_EUR * EURUSD)
phase = np.full((n_paths, n_slot), -1, np.int8)
eq = np.ones((n_paths, n_slot))
firm = np.full((n_paths, n_slot), -1, np.int8)
notional = np.zeros((n_paths, n_slot))
firm_alive = np.ones((n_paths, len(FIRMS)), bool)
cum_payout = np.zeros(n_paths)
spent = np.zeros(n_paths)
p_death_day = 1.0 - (1.0 - FIRM_DEATH_PER_YEAR) ** (1 / 365.0)
for t in range(n_days):
died = rng.random((n_paths, len(FIRMS))) < p_death_day
newly_dead = died & firm_alive
firm_alive &= ~died
if newly_dead.any():
for fi in range(len(FIRMS)):
kill = newly_dead[:, fi]
if kill.any():
hit = (firm == fi) & kill[:, None]
phase[hit] = -1
notional[hit] = 0.0
for k in range(n_slot):
free = phase[:, k] == -1
if not free.any():
continue
for fi, F in enumerate(FIRMS):
used = np.where(firm == fi, notional, 0.0).sum(axis=1)
for (size, price) in sorted(F["tickets"], key=lambda x: -x[0]):
buy = (free & firm_alive[:, fi] & (cash >= price)
& (used + size <= F["cap"]))
if not buy.any():
continue
cash[buy] -= price
spent[buy] += price
phase[buy, k] = 0
eq[buy, k] = 1.0
firm[buy, k] = fi
notional[buy, k] = size
free = free & ~buy
if not free.any():
break
if not free.any():
break
r = Rs[:, t, :]
g = G[:, t, :]
act = phase >= 0
eq_prev = eq
eq = np.where(act, eq * (1.0 + r), eq)
eq_low = np.where(act, eq_prev * (1.0 + r + g), eq)
r_low = r + g
for fi, F in enumerate(FIRMS):
s_ = act & (firm == fi)
if not s_.any():
continue
ev_t, ev_dd, ev_dl = F["ev"]
fu_ml, fu_dl = F["fu"]
in_ev = s_ & (phase == 0)
in_fu = s_ & (phase == 1)
bust = ((in_ev & ((eq_low < 1.0 - ev_dd) | (r_low < -ev_dl)))
| (in_fu & ((eq_low < 1.0 - fu_ml) | (r_low < -fu_dl))))
phase[bust] = -1
notional[bust] = 0.0
passed = in_ev & (eq >= 1.0 + ev_t) & ~bust
phase[passed] = 1
eq[passed] = 1.0
if (t + 1) % 30 == 0:
fu = phase == 1
gain = np.where(fu & (eq > 1.0), (eq - 1.0) * notional, 0.0)
net = gain.sum(axis=1) * SPLIT * (1.0 - TAX_RATE)
cum_payout += net
cash += net
eq = np.where(fu & (eq > 1.0), 1.0, eq)
eur_day = cum_payout / EURUSD / (n_days / 365.0) / 365.0
return dict(eur_day=eur_day, med=float(np.median(eur_day)),
p10=float(np.percentile(eur_day, 10)), p90=float(np.percentile(eur_day, 90)),
p_ge10=float((eur_day >= 10.0).mean()),
p_target=float((eur_day >= TARGET_EUR_DAY).mean()),
p_zero=float((cum_payout <= 0).mean()), med_spent=float(np.median(spent)))
# --------------------------------------------------- eval/funded a conto singolo (HYRO)
def single_account(cfg: tuple[float, float, float], lev: float, lens: str, start: str,
n_paths: int = 6000, seed: int = 725, block: int = 20,
xs_mode: str = "shared") -> dict:
"""P(pass eval), P(vivo 1 anno da funded), E[payout annuo] per UN conto HYRO $100k."""
rng = np.random.default_rng(seed)
R0, G0, _ = book_tuples(*cfg, start=start, xs_mode=xs_mode)
R0 = R0 - (1.0 - DELUCK) * R0.mean()
n = len(R0)
n_days = 365
n_blocks = int(np.ceil(n_days / block))
st = rng.integers(0, n - block, size=(n_paths, n_blocks))
idx = (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days]
R = R0[idx] * lev
if lens == "close":
G = np.zeros_like(R)
elif lens == "coupled":
G = G0[idx] * lev
else:
vol = R.std() * np.sqrt(365.0)
G = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=R.shape)) \
* np.clip(vol / pl.WICK_REF_VOL, 0.0, 3.0)
ev_t, ev_dd, ev_dl = FIRMS[0]["ev"]
fu_ml, fu_dl = FIRMS[0]["fu"]
NOTIONAL = 100_000.0
eq = np.ones(n_paths)
phase = np.zeros(n_paths, np.int8) # 0 eval, 1 funded, -1 morto
days_funded = np.zeros(n_paths, int)
payout = np.zeros(n_paths)
passed_ev = np.zeros(n_paths, bool)
for t in range(n_days):
alive = phase >= 0
r = R[:, t]; g = G[:, t]
eq_prev = eq
eq = np.where(alive, eq * (1 + r), eq)
eq_low = np.where(alive, eq_prev * (1 + r + g), eq)
r_low = r + g
in_ev = alive & (phase == 0)
in_fu = alive & (phase == 1)
bust = ((in_ev & ((eq_low < 1 - ev_dd) | (r_low < -ev_dl)))
| (in_fu & ((eq_low < 1 - fu_ml) | (r_low < -fu_dl))))
phase[bust] = -1
ok = in_ev & (eq >= 1 + ev_t) & ~bust
phase[ok] = 1
eq[ok] = 1.0
passed_ev |= ok
days_funded += (phase == 1)
if (t + 1) % 30 == 0:
fu = phase == 1
gain = np.where(fu & (eq > 1), (eq - 1) * NOTIONAL, 0.0)
payout += gain * SPLIT * (1 - TAX_RATE)
eq = np.where(fu & (eq > 1), 1.0, eq)
return dict(p_pass=float(passed_ev.mean()),
p_alive=float((phase[passed_ev] == 1).mean()) if passed_ev.any() else 0.0,
e_payout=float(payout.mean()),
e_payout_if_pass=float(payout[passed_ev].mean()) if passed_ev.any() else 0.0)
# ------------------------------------------------- verifica di risoluzione (1h vs 5m)
@lru_cache(maxsize=4)
def _tp_leg_gap(tf: str) -> pd.DataFrame:
"""(R, m) giornalieri della SOLA gamba TP01, misurati a risoluzione `tf`.
TP01 tiene un peso COSTANTE nel giorno, quindi il suo minimo intraday e' esattamente il
minimo del path di prezzo: si puo' calcolare a 5m e confrontare con l'1h. E' il test che
dice se l'1h basta a catturare il minimo giornaliero, invece di assumerlo.
"""
tot_r = tot_w = None
for a in ASSETS:
df = load_data(a, tf)
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
c = df["close"].values.astype(float)
lo = df["low"].values.astype(float)
hi = df["high"].values.astype(float)
prev = np.concatenate(([np.nan], c[:-1]))
tpf = mc.tp01_daily_frame(a)
day = idx.floor("D")
w = np.nan_to_num(tpf["w"].reindex(day).values)
fee = np.nan_to_num(tpf["fee"].reindex(day).values)
first = np.concatenate(([True], day[1:] != day[:-1]))
rw = np.where(w >= 0, lo / prev - 1.0, hi / prev - 1.0)
r = pd.Series(w * (c / prev - 1.0) - np.where(first, fee, 0.0), index=idx)
wk = pd.Series(w * rw - np.where(first, fee, 0.0), index=idx)
tot_r = r if tot_r is None else tot_r.add(r, fill_value=np.nan)
tot_w = wk if tot_w is None else tot_w.add(wk, fill_value=np.nan)
J = pd.concat({"r": tot_r, "w": tot_w}, axis=1).dropna()
rows = []
for d, g in J.groupby(J.index.floor("D")):
r = g["r"].values; wk = g["w"].values
cum = np.cumprod(1 + r); cp = np.concatenate(([1.0], cum[:-1]))
m = min(cum.min(), (cp * (1 + wk)).min()) - 1.0
rows.append((d, cum[-1] - 1.0, min(m, cum[-1] - 1.0)))
return pd.DataFrame(rows, columns=["d", "R", "m"]).set_index("d")
# ================================================================= main
START_WIN = "2024-01-01" # finestra comune ai 3 sleeve (XS01 nasce qui)
CFG_A = (0.75, 0.25, 0.0) # book live Deribit (TP01/SKH01) — quello che gira oggi
CFG_B = (0.55, 0.20, 0.25) # book diversificato (config B del 25/07)
CFG_TP = (1.0, 0.0, 0.0)
CFG_SKH = (0.0, 1.0, 0.0)
CFG_XS = (0.0, 0.0, 1.0)
def main() -> None:
print("=" * 106)
print(" LENTE WICK ACCOPPIATA — chiusura del follow-up dichiarato (r0725_prop_ladder §ONESTA')")
print("=" * 106)
print("\n recon MTM orario per-sleeve (TP01 daily + SKH01 230m exit-al-livello)...")
R_A, G_A, I_A = book_tuples(*CFG_A)
print(f" book A (TP01 75 / SKH01 25): {len(R_A)} giorni {I_A[0].date()} -> {I_A[-1].date()}")
# --- sanity: il recon deve riprodurre lo sleeve ufficiale
xs_off = _d(_xsec_returns())
xs_rec = xsec_daily_tuples()
common = xs_off.index.intersection(xs_rec.index)
dmax = float(np.abs(xs_off.reindex(common).values - xs_rec["R"].reindex(common).values).max())
print(f" sanity XS01 recon vs sleeve ufficiale: max|delta| chiusura = {dmax:.2e}"
f" ({'OK' if dmax < 1e-9 else 'DIVERGE'})")
# ---------------------------------------------------------- 3. la prova del difetto
print("\n" + "-" * 106)
print(" A. IL GAP NON E' INDIPENDENTE DAL GIORNO — e la dipendenza va nel verso INATTESO")
print("-" * 106)
print(f" {'book':>26} {'giorni':>7} {'m==R':>7} {'gap medio':>10} "
f"{'gap | decile PEGGIORE':>22} {'gap | decile MIGLIORE':>22}")
for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversif.)", CFG_B),
("TP01 solo", CFG_TP), ("SKH01 solo", CFG_SKH), ("XS01 solo", CFG_XS)]:
R, G, _ = book_tuples(*cfg)
if R.std() == 0:
continue
q = np.quantile(R, [0.1, 0.9])
print(f" {nm:>26} {len(R):>7d} {(G > -1e-12).mean():>6.0%} {G.mean()*100:>9.2f}pp "
f"{G[R <= q[0]].mean()*100:>21.2f}pp {G[R >= q[1]].mean()*100:>21.2f}pp")
print("\n Lettura — il contrario di quello che si assume di solito: l'escursione intraday e'")
print(" piu' PROFONDA nei giorni che finiscono BENE (recuperi a V) che in quelli che finiscono")
print(" male, perche' un giorno brutto scende tutto il giorno e CHIUDE sul minimo (m==R nel")
print(" ~29% dei giorni). Il breach pero' si valuta sul minimo, quindi cio' che conta e' quanto")
print(" gap si aggiunge NEI GIORNI BRUTTI — e li' il gap vero e' ~1/3 di quello nei giorni buoni.")
print(" Un'estrazione indipendente usa la STESSA distribuzione ovunque -> carica i giorni")
print(" brutti con la coda che nella realta' appartiene ai giorni buoni.")
# --- la misura che chiude la questione: quanti breach in piu' inventa la lente indipendente
print("\n" + "-" * 106)
print(" A-bis. CONTEGGIO EMPIRICO DEI BREACH sui giorni STORICI (nessun bootstrap)")
print("-" * 106)
rng = np.random.default_rng(725)
print(f" {'book':>26} {'leva':>5} {'regola':>22} {'close-only':>11} "
f"{'ACCOPPIATO':>11} {'indipendente':>13} {'gonfiaggio':>11}")
for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversif.)", CFG_B)]:
R0, G0, _ = book_tuples(*cfg, start=START_WIN)
for lev in (0.75, 1.0):
R, G = R0 * lev, G0 * lev
vol = R.std() * np.sqrt(365.0)
n_rep = 400
Gi = -np.exp(rng.normal(pl.WICK_MU, pl.WICK_SIGMA, size=(n_rep, len(R)))) \
* np.clip(vol / pl.WICK_REF_VOL, 0.0, 3.0)
for lab, thr in (("daily-loss 4% (HYRO)", 0.04), ("daily-loss 5% (FTMO)", 0.05)):
b_cl = float((R < -thr).mean())
b_co = float((R + G < -thr).mean())
b_in = float((R[None, :] + Gi < -thr).mean())
infl = (b_in / b_co) if b_co > 0 else np.inf
print(f" {nm:>26} {lev:>5.2f} {lab:>22} {b_cl:>10.2%} "
f"{b_co:>10.2%} {b_in:>12.2%} {infl:>10.1f}x")
# geometria dei gap: confronto diretto fra le due lenti
print("\n" + "-" * 106)
print(" B. GEOMETRIA DEI GAP — accoppiato (recon) vs indipendente (calibrazione 25/07)")
print("-" * 106)
q_ind = pl.wick_percentiles()
print(f" {'book':>28} {'p50':>9} {'p90':>9} {'p99':>9} {'peggiore':>10} {'vol annua':>10}")
for nm, cfg in [("A TP01/SKH01 (live)", CFG_A), ("B +XS01 (diversificato)", CFG_B),
("XS01 solo", CFG_XS)]:
R, G, _ = book_tuples(*cfg)
if R.std() == 0:
continue
p = np.percentile(-G, [50, 90, 99])
print(f" {nm:>28} {-p[0]*100:>8.2f}pp {-p[1]*100:>8.2f}pp {-p[2]*100:>8.2f}pp "
f"{G.min()*100:>9.2f}pp {R.std()*np.sqrt(365)*100:>9.1f}%")
print(f" {'INDIPENDENTE (25/07)':>28} {-q_ind[0.50]*100:>8.2f}pp {-q_ind[0.90]*100:>8.2f}pp "
f"{-q_ind[0.99]*100:>8.2f}pp {'—':>10} {'(scalato)':>10}")
# ---------------------------------------------------------- 4. conto singolo HYRO
START = START_WIN
print("\n" + "-" * 106)
print(f" C. UN CONTO HYRO $100k (max-loss 6% statico, daily 4%) — finestra {START}+, de-luck x{DELUCK}")
print("-" * 106)
print(f" {'config':>26} {'leva':>5} {'lente':>10} {'P(pass eval)':>13} "
f"{'P(vivo 1a)':>11} {'E[payout/a]':>12}")
for nm, cfg in [("A book live 75/25", CFG_A), ("B +XS01 55/20/25", CFG_B)]:
for lev in (0.50, 0.75, 1.00):
for lens in ("close", "indip", "coupled"):
r = single_account(cfg, lev, lens, START)
print(f" {nm:>26} {lev:>5.2f} {lens:>10} {r['p_pass']:>13.1%} "
f"{r['p_alive']:>11.1%} {r['e_payout']:>11,.0f}$")
print()
# ------------------------------------------------- 4-bis. le tre verifiche che possono ribaltare
print("-" * 106)
print(" C-bis. VERIFICHE — un risultato 'nessuna differenza' va provato, non assunto")
print("-" * 106)
print("\n (1) RISOLUZIONE: il wick 1h cattura davvero il minimo del giorno? (gamba TP01, 5m vs 1h)")
for tf in ("1h", "5m"):
D = _tp_leg_gap(tf)
G = (D["m"] - D["R"]).values
k = np.isfinite(G) & (np.abs(D["R"].values) > 0) # i giorni flat non hanno escursione
print(f" @{tf}: p50 {-np.percentile(-G[k], 50)*100:>6.3f}pp "
f"p90 {-np.percentile(-G[k], 90)*100:>6.3f}pp "
f"p99 {-np.percentile(-G[k], 99)*100:>6.3f}pp "
f"peggiore {G[k].min()*100:>6.2f}pp (n={k.sum()} giorni non-flat)")
print(" -> se coincidono, la lente accoppiata a 1h NON e' un tetto di risoluzione.")
print("\n (2) RICONCILIAZIONE col 24/07 (recon MTM indipendente, finestra piena): "
"config A @0.75x, P(vivo 1a)")
for st, lab in (("2019-01-01", "2019-03+ FULL"), (START, "2024+ comune")):
for lens in ("close", "coupled"):
r = single_account(CFG_A, 0.75, lens, st)
print(f" {lab:>14} {lens:>8}: P(pass) {r['p_pass']:>5.1%} "
f"P(vivo) {r['p_alive']:>5.1%} E[payout] {r['e_payout']:>6,.0f}$")
print(" -> il 24/07 riportava 58% sulla finestra piena; il 25/07 (wick indipendente) 10%.")
print("\n (3) BOUND SEVERO su XS01 (ogni gamba al proprio peggio insieme) — config B @0.75x")
for xm, lab in (("shared", "ordine condiviso (base)"), ("perleg", "per-gamba (bound severo)")):
R, G, _ = book_tuples(*CFG_B, start=START, xs_mode=xm)
r = single_account(CFG_B, 0.75, "coupled", START, xs_mode=xm)
print(f" {lab:>26}: gap p90 {-np.percentile(-G, 90)*100:>6.2f}pp "
f"peggiore {G.min()*100:>6.2f}pp | P(pass) {r['p_pass']:>5.1%} "
f"P(vivo) {r['p_alive']:>5.1%} E[payout] {r['e_payout']:>6,.0f}$")
print(" -> se la config B regge a ENTRAMBI gli estremi, la conclusione non dipende dalla convenzione.")
# ---------------------------------------------------------- 5. la scala di conti
print("\n" + "-" * 106)
print(f" D. LA SCALA DI CONTI — {HORIZON_MONTHS} mesi, EUR {START_EUR:.0f} di cassa, "
f"max {MAX_CONCURRENT} conti, morte-firm {FIRM_DEATH_PER_YEAR:.0%}/a")
print("-" * 106)
cfgs = [CFG_A, CFG_B, CFG_TP, CFG_SKH, CFG_XS]
POLS = {
"CONC-2SL ogni conto = book live": [0] * MAX_CONCURRENT,
"CONC-DIV ogni conto = diversificato": [1] * MAX_CONCURRENT,
"SPARSO 1 sleeve per conto": [2, 4, 3, 2, 4, 3],
"MISTO meta' div / meta' single": [1, 4, 1, 2, 1, 4],
}
for lens in ("close", "indip", "coupled"):
tag = {"close": "CLOSE-ONLY (tetto)", "indip": "WICK INDIPENDENTE (pavimento 25/07)",
"coupled": "WICK ACCOPPIATO (la lente onesta)"}[lens]
for lev in (0.50, 1.00):
print(f"\n {tag} — LEVA {lev:.2f}x")
print(f" {'politica':>40} {'EUR/g med':>10} {'p10':>7} {'p90':>7} "
f"{'P(>=10/g)':>10} {'P(>=50/g)':>10} {'P(zero)':>8}")
for pname, slots in POLS.items():
r = simulate(cfgs, slots, n_paths=3000, lev=lev, seed=20260725,
lens=lens, start=START)
print(f" {pname:>40} {r['med']:>10.2f} {r['p10']:>7.2f} {r['p90']:>7.2f} "
f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}")
print("\n" + "=" * 106)
if __name__ == "__main__":
main()