"""SL-ANCHOR — il disaster-SL del libro live e' un PAVIMENTO o un −30% ROTOLANTE? Il nodo lasciato aperto da `docs/research/SPEC-scale-key.md` §4.5: la guardia che morde per prima sul gradino di leva e' il costo del disaster-SL (B = 37,5% dell'equity a k=1,25). La SPEC osserva *dalla lettura del codice* che `src/live/execution.py::ensure_disaster_sl` ri-ancora lo stop al mark a ogni giro, e conclude: «B e' il costo di UN episodio, un ribasso lento puo' produrne piu' d'uno», dichiarando la cosa **non ricostruibile a posteriori** perche' «servirebbe `pos_prima` nel log». Quella dichiarazione di non-misurabilita' e' un'IPOTESI, e qui viene sfidata (regola del 26/07). Il log di produzione stampa, a ogni giro e per ogni asset, **l'azione** (`HOLD` = nessun ordine = posizione invariata) e **lo stato + il prezzo di trigger del bracket**; e nello stato `ok` quel prezzo e' letto dal venue (`get_open_orders -> trigger_price`), non calcolato. Un `placed` su una riga `HOLD (a target)` e' quindi esattamente l'evento cercato: bracket ricostruito a posizione ferma. La domanda ERA gia' rispondibile col log che c'e'. SOLA LETTURA. Questo script: - non importa nulla che invii ordini se non per ISPEZIONARLO (la sottoclasse di §1 ha `_post` che SOLLEVA: se il codice di produzione toccasse la rete, il test fallirebbe rumorosamente); - tocca il venue solo con `--venue`, solo `get_open_orders` (lettura), 2 richieste, e si rifiuta di partire nei minuti :05-:10 (cron_book) e :24-:30 (cron_chain). uv run python scripts/research/r0823_sl_anchor.py # attesa + log + simulazione uv run python scripts/research/r0823_sl_anchor.py --venue # + riscontro sul conto (2 letture) """ from __future__ import annotations import re import sys import time from collections import Counter from datetime import datetime, timezone from pathlib import Path import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from src.data.downloader import load_data # noqa: E402 from src.live.deribit import DISASTER_LABEL, disaster_stop_price # noqa: E402 from src.live.execution import DeribitTrader # noqa: E402 LOG = PROJECT_ROOT / "logs" / "cron_book.log" SL_PCT = 0.30 # config/live.json -> disaster_sl_pct TOL = 0.05 # tolleranza relativa cablata in ensure_disaster_sl AMT_TOL = 0.10 # tolleranza sull'amount, idem H = "=" * 96 def sec(t: str): print("\n" + H + f"\n{t}\n" + H) # ═══════════════════════════════════════════════════════════════════════════════════════════════ # 1. L'ATTESA — derivata dal CODICE, scritta prima di guardare i dati # ═══════════════════════════════════════════════════════════════════════════════════════════════ class _TraderStub(DeribitTrader): """Esercita il VERO `ensure_disaster_sl` senza rete e senza ordini. Non e' una re-implementazione: il metodo sotto esame e' quello di produzione, importato. Sono stubbate solo le sue dipendenze di I/O. `_post` SOLLEVA -> se un percorso non stubbato provasse a parlare col venue, l'esercizio si romperebbe invece di riuscire in silenzio. """ def __init__(self, pos_usd: float, mark: float): self._pos = pos_usd self._mark = mark self.book: list[dict] = [] # "ordini aperti" finti self.eventi: list[tuple] = [] # (azione, prezzo) def _post(self, path, payload): # noqa: D102 raise AssertionError(f"RETE VIETATA in questo esercizio (path={path})") def position_usd(self, instrument): return self._pos def mark_price(self, instrument): return self._mark def open_orders(self, instrument): return list(self.book) def cancel_order(self, order_id): self.book = [o for o in self.book if o["order_id"] != order_id] self.eventi.append(("cancel", order_id)) return {} def place_disaster_sl(self, instrument, side_held, amount, stop_price, label=DISASTER_LABEL): from src.live.execution import Fill oid = f"stub-{len(self.eventi)}" self.book.append({"order_id": oid, "label": DISASTER_LABEL, "amount": amount, "trigger_price": stop_price}) self.eventi.append(("place", stop_price)) return Fill(instrument, "sell", amount, 0.0, None, 0.0, oid, "untriggered", True) def attesa(): sec("1. ATTESA — cosa dice il CODICE, prima di guardare i dati") print("""`ensure_disaster_sl` (src/live/execution.py:196-222), a ogni giro orario: mark = self.mark_price(instrument) <- MARK CORRENTE, non il prezzo d'ingresso want_stop = disaster_stop_price(inst, mark, sl_pct) <- 0,70 * mark_ora (long) tiene il bracket esistente SOLO se |stp - want_stop| / want_stop < 0,05 E |amt - want| < 10%*want altrimenti -> cancel_order(...) + place_disaster_sl(..., want_stop) Il trigger NON e' funzione del prezzo d'ingresso della posizione: e' funzione del mark dell'ora. Sostituendo stp = 0,70*M_vecchio e want = 0,70*M_ora, il fattore 0,70 si semplifica: |0,70*M_vec - 0,70*M_ora| / (0,70*M_ora) = |M_vec/M_ora - 1| >= 0,05 che e' una condizione sul MARK, non sullo stop. Soglie (esatte, non arrotondate): salita M_ora >= M_vec/0,95 -> +5,263% -> lo stop sale (trailing "buono") discesa M_ora <= M_vec/1,05 -> -4,762% -> lo stop SCENDE (il -30% insegue il prezzo giu') ATTESA DICHIARATA: **ROTOLANTE e SIMMETRICO** — nel codice c'e' un solo `abs()`, non due rami. ATTESA 2: esiste un SECONDO innesco, che la SPEC non cita — il controllo sull'AMOUNT. Ogni ribilancio cambia la size -> `|amt - want_amount| >= 10%` -> bracket ricostruito a 0,70*mark_ora ANCHE se il prezzo non si e' mosso del 5%. Su un libro vol-targeted questo innesco e' il piu' frequente dei due. ATTESA 3 (conseguenza, e va nel verso OPPOSTO al timore della SPEC): uno stop che scende insieme al prezzo si allontana dal mercato in un ribasso -> viene toccato MENO, non piu'. Cio' che si perde non e' "piu' episodi", e' il PAVIMENTO: la perdita dall'INGRESSO non e' piu' limitata al 30%.""") # --- esercizio del codice di produzione su un percorso sintetico (nessuna rete, nessun ordine) print("\n Esercizio del VERO `ensure_disaster_sl` (stub di I/O, `_post` solleva):") inst = "BTC_USDC-PERPETUAL" righe = [] for nome, mult in (("mark FERMO", 1.000), ("mark +4,0% (sotto soglia)", 1.040), ("mark +5,5% (sopra soglia)", 1.055), ("mark -4,0% (sotto soglia)", 0.960), ("mark -5,5% (sopra soglia)", 0.945)): t = _TraderStub(pos_usd=200.0, mark=70_000.0) r0 = t.ensure_disaster_sl(inst, SL_PCT) # primo piazzamento t._mark = 70_000.0 * mult # il mark si muove, la posizione NO r1 = t.ensure_disaster_sl(inst, SL_PCT) righe.append((nome, r0["stop"], r1["state"], r1.get("stop"))) print(f" {'scenario':<28} {'stop iniziale':>14} {'esito':>10} {'stop dopo':>14} posizione") for n, s0, st, s1 in righe: print(f" {n:<28} {s0:>14,.1f} {st:>10} {s1:>14,.1f} INVARIATA") su = [r for r in righe if "+5,5" in r[0]][0] giu = [r for r in righe if "-5,5" in r[0]][0] assert su[2] == "placed" and giu[2] == "placed", "atteso ri-piazzamento in ENTRAMBI i versi" assert righe[0][2] == "ok" and righe[1][2] == "ok" and righe[3][2] == "ok" print(f"\n -> il codice di produzione ri-piazza in ENTRAMBI i versi: lo stop SCENDE da " f"{giu[1]:,.1f} a {giu[3]:,.1f} con la posizione ferma.") print(" (asserzione superata: nessuna chiamata di rete e' partita, `_post` avrebbe sollevato)") # controllo positivo: la stessa asserzione applicata a un pavimento DEVE fallire class _Pavimento(_TraderStub): def ensure_disaster_sl(self, instrument, sl_pct): if not self.book: return _TraderStub.ensure_disaster_sl(self, instrument, sl_pct) return {"state": "ok", "stop": self.book[0]["trigger_price"]} p = _Pavimento(pos_usd=200.0, mark=70_000.0) p.ensure_disaster_sl(inst, SL_PCT) p._mark = 70_000.0 * 0.945 assert p.ensure_disaster_sl(inst, SL_PCT)["state"] == "ok", "controllo positivo rotto" print(" CONTROLLO POSITIVO: su un'implementazione a PAVIMENTO lo stesso esercizio da' `ok`") print(" -> l'esercizio ha potenza: distingue le due implementazioni, non passa e basta.") return dict(soglia_su=1 / 0.95 - 1, soglia_giu=1 / 1.05 - 1) # ═══════════════════════════════════════════════════════════════════════════════════════════════ # 2. LA MISURA — dal log di produzione (che porta letture del venue) # ═══════════════════════════════════════════════════════════════════════════════════════════════ _RUN = re.compile(r"^===== (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})Z cron_book =====") _ASSET = re.compile(r"^ (BTC|ETH) TP ([+-][\d.]+) . SKH ([+-]\d)\(([^)]*)\) -> net \$([+-][\d,]+)" r" \| pos \$([+-][\d,]+) -> (.*)$") _DSL = re.compile(r"^ disaster-SL: (\w[\w-]*)(?: @ \$([\d,.]+))?") _REC = re.compile(r"^ reconcile: pos \$([-\d,]+)") _FILL = re.compile(r"^ -> (BUY|SELL) ([\d.]+) @ \$([\d,.]+)") def _f(s: str) -> float: return float(s.replace(",", "")) def parse_log() -> list[dict]: runs: list[dict] = [] cur = asset = None for ln in LOG.read_text().splitlines(): m = _RUN.match(ln) if m: cur = {"ts": pd.Timestamp(m.group(1), tz="UTC"), "assets": []} runs.append(cur); asset = None; continue if cur is None: continue m = _ASSET.match(ln) if m: asset = dict(asset=m.group(1), tp=float(m.group(2)), skh=int(m.group(3)), net=_f(m.group(5)), pos=_f(m.group(6)), action=m.group(7), dsl_state=None, dsl_stop=None, n_fill=0, pos_after=None) cur["assets"].append(asset); continue if asset is None: continue if _FILL.match(ln): asset["n_fill"] += 1; continue m = _REC.match(ln) if m: asset["pos_after"] = _f(m.group(1)); continue m = _DSL.match(ln) if m: asset["dsl_state"] = m.group(1) asset["dsl_stop"] = _f(m.group(2)) if m.group(2) else None return runs def misura_log(soglie: dict) -> dict: sec("2. LA MISURA — log di produzione `logs/cron_book.log` (1 giro/ora dal 2026-06-23)") runs = parse_log() righe = [(r["ts"], a) for r in runs for a in r["assets"]] st = Counter(a["dsl_state"] for _, a in righe) span = (runs[-1]["ts"] - runs[0]["ts"]).total_seconds() / 3600 print(f" giri: {len(runs)} ({runs[0]['ts']:%Y-%m-%d %H:%M} -> {runs[-1]['ts']:%Y-%m-%d %H:%M} UTC, " f"{span/24:.1f} giorni)") print(f" righe-asset: {len(righe)} | stati disaster-SL: {dict(st)}") print(f" (i {st[None]} `None` sono giri usciti PRIMA del passo disaster-SL — conto offline /" " feed stantio: non sono uno stato del bracket, sono l'assenza della misura)") gap = pd.Series([r["ts"] for r in runs]).diff().dt.total_seconds() / 3600 print(f" cadenza del cron: mediana {gap.median():.2f}h · max {gap.max():.2f}h · " f"buchi >2h: {int((gap > 2).sum())}" " <- durante un buco lo stop e' CONGELATO (nessun giro = nessun ri-ancoraggio)") # --- L'EVENTO CERCATO: bracket ricostruito a POSIZIONE FERMA ------------------------------- print("\n a) `placed` = bracket cancellato e ricostruito. La SPEC li dichiara «non separabili»") print(" dalle prime aperture: FALSO — la riga porta gia' l'azione dello stesso giro.") placed = [(t, a) for t, a in righe if a["dsl_state"] == "placed"] hold = [(t, a) for t, a in placed if a["action"].startswith("HOLD") and a["n_fill"] == 0] apert = [(t, a) for t, a in placed if abs(a["pos"]) < 1.0] ribil = [(t, a) for t, a in placed if abs(a["pos"]) >= 1.0 and a["n_fill"] > 0] print(f" {len(placed)} `placed` totali = {len(apert)} prime aperture (pos=0) + " f"{len(ribil)} ribilanci (innesco AMOUNT) + {len(hold)} A POSIZIONE FERMA (innesco PREZZO)") print(f"\n {'timestamp':<21} {'as':<4} {'azione':<16} {'pos $':>7} {'ordini':>7} {'nuovo stop':>12}") for t, a in hold: print(f" {t:%Y-%m-%d %H:%M:%S} {a['asset']:<4} {a['action'][:16]:<16} " f"{a['pos']:>7,.0f} {a['n_fill']:>7} {a['dsl_stop']:>12,.1f}") print(f"\n -> {len(hold)} osservazioni in cui NESSUN ordine e' partito, la posizione non e'") print(" cambiata, e il trigger sul book e' stato comunque spostato. **UNA sarebbe bastata.**") # --- serie del trigger, e taglia dei salti vs la soglia predetta --------------------------- print("\n b) serie del trigger on-book (nello stato `ok` il numero e' LETTO dal venue,") print(" `get_open_orders -> trigger_price`: non e' un valore che il libro ricalcola)") salti = [] for A in ("BTC", "ETH"): prev = None print(f"\n --- {A} ---") for t, a in righe: if a["asset"] != A: continue if a["dsl_state"] in (None, "flat"): if prev is not None: print(f" {t:%Y-%m-%d %H:%M} FLAT (posizione chiusa -> bracket cancellato)") prev = None; continue s = a["dsl_stop"] if prev is None: print(f" {t:%Y-%m-%d %H:%M} primo piazzamento stop ${s:>10,.1f}") elif abs(s - prev) > 1e-9: d = s / prev - 1 innesco = "PREZZO (pos. ferma)" if a["action"].startswith("HOLD") else "AMOUNT (ribilancio)" salti.append((A, t, d, innesco)) print(f" {t:%Y-%m-%d %H:%M} stop ${prev:>10,.1f} -> ${s:>10,.1f} ({d:+6.2%}) " f"innesco: {innesco}") prev = s sp = [d for _, _, d, i in salti if i.startswith("PREZZO")] print(f"\n salti a posizione ferma: {['%+.2f%%' % (x*100) for x in sp]}") print(f" soglia predetta al §1 per il ri-piazzamento in salita: {soglie['soglia_su']:+.3%}") print(f" -> tutti e {len(sp)} stanno appena SOPRA la soglia: la misura non dice solo «si muove»,") print(" dice che si muove **quando e quanto il codice prevede**.") print("\n c) ONESTA' SUL CAMPIONE — quello che NON e' stato osservato:") print(f" ri-ancoraggi verso il BASSO a posizione ferma: 0 su {len(sp)}.") print(" Il campione e' di 2 mesi e UN SOLO REGIME: nella finestra BTC va da ~$66,5k a ~$78,6k") print(" e non c'e' mai stato un calo del mark >4,76% mentre una posizione era aperta.") print(" La simmetria del ri-ancoraggio NON e' quindi un fatto osservato: e' un fatto di") print(" CODICE, esercitato al §1 (un solo `abs()`, ri-piazzamento provato in entrambi i versi).") return dict(n_runs=len(runs), n_hold=len(hold), n_placed=len(placed), gap_max=float(gap.max())) # ═══════════════════════════════════════════════════════════════════════════════════════════════ # 3. RISCONTRO SUL VENUE — solo lettura, solo il percorso che il libro usa gia' # ═══════════════════════════════════════════════════════════════════════════════════════════════ def riscontro_venue(runs_info: dict): sec("3. RISCONTRO SUL VENUE (percorso del conto, SOLA LETTURA)") print(""" La storia degli ordini (`get_order_history_by_currency` / `get_order_state`) NON e' raggiungibile: il gateway che il libro usa (cerbero-mcp) espone 32 tool Deribit e nessuno di essi e' la storia degli ordini (verificato in `cerbero_mcp/exchanges/deribit/tools.py`: ticker, orderbook, positions, account_summary, open_orders, trade_history, place/cancel...). Le chiavi API diritte di Deribit appartengono a un ALTRO progetto: usarle sarebbe inventare un percorso, e la domanda non ne ha bisogno. Si usa quindi cio' che il percorso sanzionato offre: `get_open_orders`.""") m = datetime.now(timezone.utc).minute if 5 <= m <= 10 or 24 <= m <= 30: print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:07) / cron_chain (:25). Non interrogo.") return from src.live.deribit import DeribitRead r = DeribitRead() print(f"\n {'strumento':<22} {'trigger on-book':>16} {'amount':>10} creato (UTC)") for i, inst in enumerate(("BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL")): if i: time.sleep(0.6) # <= 2 req/s try: br = [o for o in r.open_orders(inst) if (o.get("label") or "") == DISASTER_LABEL] except Exception as e: # noqa: BLE001 print(f" {inst:<22} lettura fallita: {type(e).__name__}: {e}") continue for o in br: trg = float(o.get("trigger_price") or o.get("stop_price") or o.get("price") or 0) ct = o.get("creation_timestamp") when = pd.Timestamp(int(ct), unit="ms", tz="UTC").strftime("%Y-%m-%d %H:%M:%S") if ct else "?" print(f" {inst:<22} {trg:>16,.2f} {float(o.get('amount') or 0):>10.4f} {when}") if not br: print(f" {inst:<22} nessun bracket (posizione flat)") print(""" Da leggere insieme al §2: il `creation_timestamp` del bracket vivo e' l'ora dell'ULTIMO `placed` del log, non quella di apertura della posizione. L'oggetto-ordine sul venue e' piu' GIOVANE della posizione che protegge: e' stato distrutto e ricreato mentre la posizione viveva.""") # ═══════════════════════════════════════════════════════════════════════════════════════════════ # 4. LA CONSEGUENZA — quanto costa davvero, sulla storia vera di BTC/ETH # ═══════════════════════════════════════════════════════════════════════════════════════════════ def simula(df: pd.DataFrame, modo: str, ore_cron: int, sl: float = SL_PCT, tol: float = TOL) -> dict: """Replica la regola di produzione su barre orarie reali. `modo="roll"` = `ensure_disaster_sl` com'e' (stop = 0,70*mark, ri-ancorato oltre `tol`); `modo="floor"` = la controfattuale che la SPEC assumeva (stop fissato all'ingresso, mai mosso). Il runner e' VIVO e la posizione e' SEMPRE LONG (caso peggiore per l'accumulo di episodi: dopo ogni scatto si rientra al giro successivo). `ore_cron` = cadenza dei giri. """ c = df["close"].to_numpy(float); lo = df["low"].to_numpy(float); op = df["open"].to_numpy(float) _dt = pd.to_datetime(df["datetime"]) if getattr(_dt.dt, "tz", None) is not None: # confronti sempre naive-UTC, mai misti _dt = _dt.dt.tz_convert("UTC").dt.tz_localize(None) ts = _dt.to_numpy() n = len(c) stop = anchor = entry = None epis = [] peggio_da_ingresso = 0.0 for i in range(1, n): if (i % ore_cron) == 0: # il giro del cron: e' l'UNICO momento in cui mark = c[i - 1] # si apre, si rientra e si ri-ancora if stop is None: # (ri)apertura + primo bracket entry = anchor = mark stop = mark * (1 - sl) elif modo == "roll": want = mark * (1 - sl) if abs(stop - want) / want >= tol: stop, anchor = want, mark if stop is None: continue peggio_da_ingresso = min(peggio_da_ingresso, lo[i] / entry - 1) if lo[i] <= stop: # scatto (gap-through al peggiore fra open e stop) fill = min(op[i], stop) epis.append(dict(t=ts[i], entry=entry, anchor=anchor, stop=stop, fill=fill, perdita_da_ancora=fill / anchor - 1, perdita_da_ingresso=fill / entry - 1)) stop = entry = anchor = None return dict(episodi=epis, n=len(epis), peggior_perdita_da_ingresso=peggio_da_ingresso) def conseguenza(): sec("4. LA CONSEGUENZA — quanti disaster-SL si accumulano davvero (storia reale BTC/ETH 1h)") print(" Regola di produzione replicata bar-per-bar. Runner VIVO, posizione SEMPRE LONG e") print(" rientro al giro successivo dopo ogni scatto = caso peggiore per l'accumulo.") print(" GRIGLIA: 2 asset x 2 modi (roll / floor) x 3 cadenze (1h / 4h / 24h) = 12 celle.\n") out = {} print(f" {'asset':<5} {'cadenza':>8} {'modo':>6} {'episodi':>8} {'peggior scatto':>15} " f"{'max in 30g':>11} peggior DD dall'ingresso (*)") for asset in ("BTC", "ETH"): d = load_data(asset, "1h") for ore in (1, 4, 24): for modo in ("roll", "floor"): r = simula(d, modo, ore) e = r["episodi"] pe = min((x["perdita_da_ancora"] for x in e), default=float("nan")) if e: tt = pd.Series([pd.Timestamp(x["t"]) for x in e]) m30 = int(max(((tt >= t) & (tt < t + pd.Timedelta("30D"))).sum() for t in tt)) else: m30 = 0 out[(asset, ore, modo)] = r print(f" {asset:<5} {str(ore)+'h':>8} {modo:>6} {r['n']:>8} " f"{('%.1f%%' % (pe*100)) if e else ' —':>15} {m30:>11} " f"{r['peggior_perdita_da_ingresso']:>+.1%}") # elenco degli scatti alla cadenza DI PRODUZIONE (1h) e alla peggiore (24h) for ore in (1, 24): for modo in ("roll", "floor"): e = out[(asset, ore, modo)]["episodi"] if e: print(f" {asset} {str(ore)+'h':>3} {modo:<5}: " + ", ".join(f"{pd.Timestamp(x['t']):%Y-%m-%d %H:%M} " f"(da ingresso {x['perdita_da_ingresso']:+.1%})" for x in e[:8])) print(""" COME SI LEGGE: · MECCANISMO: uno stop che SEGUE il prezzo in discesa si ALLONTANA dal mercato -> per scattare serve una caduta di ~26,5-30% DENTRO un solo giro di cron (l'ancora sta entro +5,26%/-4,76% dal mark). Piu' fitto e' il cron, piu' lo stop e' irraggiungibile. · A CADENZA DI PRODUZIONE (1h), su ~7-8 anni: BTC 0 scatti, ETH 1. Mai piu' di 1 in 30 giorni. -> **l'accumulo che la SPEC temeva NON si verifica alla configurazione che gira.** · ⚠️ MA LA RELAZIONE NON E' MONOTONA E IL TIMORE NON E' INFONDATO: a 4h BTC fa 2 scatti contro l'1 del pavimento, a 24h ETH ne fa 5 con 2 dentro 30 giorni. Con un cron lento l'ancora resta alta dopo una salita, lo stop siede vicino sotto il mercato, il crollo lo prende, si rientra e la gamba successiva lo riprende. **Il rischio di accumulo e' funzione della CADENZA DEL CRON, non di k**: il gradino di leva non lo crea e non lo amplifica. · ⚠️ E qui c'e' un aggancio operativo: il docstring di `scripts/live/book_execute.py` prescrive «ogni ~230 minuti» (la griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giri in 1442 ore). Chi "correggesse" la cadenza verso il docstring sposterebbe il libro dalla riga 1h alla riga 4h — quella in cui BTC raddoppia gli scatti. (*) ⚠️ CAVEAT sulla colonna, e sul suo artefatto: in questa lente la posizione e' SEMPRE aperta dal 2018, quindi in modo `roll` l'"ingresso" e' un prezzo di anni prima e il -48%/-61% e' il drawdown di un buy&hold pluriennale, NON di un trade del libro (che dura ore-giorni). E' esattamente la trappola n.10 del brief: il confronto va appaiato per INGRESSO. Il §4b rifa' la stessa domanda su durate realistiche.""") # cadute massime realizzabili dentro un giro: e' la distanza che separa il rotolante dallo scatto print("\n Quanto deve cadere il prezzo DENTRO un giro perche' il rotolante scatti:") print(" (lo stop e' 0,70*ancora e l'ancora sta entro +5,26%/-4,76% dal mark -> serve ~26,5-30%)") print(f"\n {'asset':<5} {'finestra':>9} {'caduta max storica (low vs close t-w)':>42}") for asset in ("BTC", "ETH"): d = load_data(asset, "1h") for w in (1, 4, 24): m = float((d["low"].rolling(w).min() / d["close"].shift(w) - 1).min()) print(f" {asset:<5} {str(w)+'h':>9} {m:>41.2%}") return out def cicli_bounded(df: pd.DataFrame, modo: str, hold_h: int, sl: float = SL_PCT, tol: float = TOL) -> dict: """Stessa regola, ma su TRADE di durata realistica: cicli NON sovrapposti di `hold_h` ore, ogni ciclo entra al suo inizio col proprio prezzo. Risponde alla domanda «quanto puo' perdere un trade dall'INGRESSO prima che lo stop lo fermi», senza l'artefatto della posizione perpetua.""" c = df["close"].to_numpy(float); lo = df["low"].to_numpy(float); op = df["open"].to_numpy(float) n = len(c) peggiori, scattati, sfondati = [], 0, 0 for a in range(1, n - hold_h, hold_h): entry = c[a - 1] stop = entry * (1 - sl) worst = 0.0 fired = False for i in range(a, a + hold_h): mark = c[i - 1] if modo == "roll": want = mark * (1 - sl) if abs(stop - want) / want >= tol: stop = want worst = min(worst, lo[i] / entry - 1) if lo[i] <= stop: fired = True worst = min(worst, min(op[i], stop) / entry - 1) break peggiori.append(worst) scattati += fired sfondati += (worst < -sl) and not fired # perdita >30% SENZA che lo stop fermasse p = np.array(peggiori) return dict(n=len(p), peggiore=float(p.min()), scattati=scattati, sfondati=sfondati, q_sfondati=sfondati / len(p)) def bounded(): sec("4b. LA PERDITA DALL'INGRESSO, su trade di durata realistica (cicli non sovrapposti)") print(" Il libro tiene una posizione ore-giorni, non anni. Qui ogni ciclo ha il PROPRIO ingresso.") print(" GRIGLIA: 2 asset x 2 modi x 3 durate (24h / 7g / 30g) = 12 celle (totale script: 24).\n") print(f" {'asset':<5} {'durata':>7} {'modo':>6} {'cicli':>7} {'scatti':>7} " f"{'peggior DD da ingresso':>23} {'cicli oltre -30% SENZA scatto':>31}") for asset in ("BTC", "ETH"): d = load_data(asset, "1h") for h, lab in ((24, "24h"), (168, "7g"), (720, "30g")): for modo in ("roll", "floor"): r = cicli_bounded(d, modo, h) print(f" {asset:<5} {lab:>7} {modo:>6} {r['n']:>7} {r['scattati']:>7} " f"{r['peggiore']:>23.1%} {r['sfondati']:>8} ({r['q_sfondati']:>6.2%})") print(""" Qui il rotolante mostra il suo vero difetto, senza artefatti: su trade veri lascia passare perdite dall'ingresso ben oltre il 30% SENZA scattare, mentre il pavimento no. Non e' un costo che il gradino di leva crea ne' amplifica: e' una proprieta' di oggi, ed e' il motivo per cui il disaster-SL NON e' un "massimo -30% per trade". Cio' che limita la perdita di un trade nel libro sono le uscite software di TP01/SKH01, non questo bracket.""") def costo(out: dict): sec("5. IL NUMERO CHE SBLOCCA (o chiude) IL GRADINO DI LEVA") for k in (1.00, 1.25, 1.50): b = 2 * 0.50 * k * SL_PCT print(f" k={k:.2f} -> costo di UN episodio = 2*frac*k*sl_pct = {b:.1%} dell'equity" f" (margine su 50%: {0.50/b:.2f}x)") print(""" La SPEC temeva che il 37,5% a k=1,25 fosse il costo di UN episodio fra molti, e che quindi il margine 1,33x non fosse quello vero. Le misure: 1) B copre GIA' il caso peggiore per asset. Il fattore `len(ASSETS)*frac` e' il lordo su ENTRAMBE le gambe: «un episodio» in G2 significa gia' BTC **e** ETH stoppati insieme. Alla cadenza di produzione questo non e' mai successo in ~7-8 anni (BTC 0 scatti, ETH 1, in anni diversi). 2) Con RUNNER VIVO a 1h gli scatti sono 0-1 in tutta la storia e mai piu' di 1 in 30 giorni -> nessun accumulo da sommare al 37,5%. 3) Nella finestra in cui il disaster-SL serve davvero — l'OUTAGE — il ri-ancoraggio e' SPENTO PER COSTRUZIONE: nessun giro di cron, nessuna cancellazione, nessun ri-piazzamento, e nemmeno un rientro. Lo stop resta dov'era: **un episodio solo, per costruzione.** Il log misura la taglia di quella finestra: nessun buco del cron oltre 2 ore in 60 giorni. => Il 37,5% resta il costo di UN episodio: il margine 1,33x di G6 e' quello vero, e la gamba WORST-DAY del gradino regge. **Il nodo e' sciolto, e non blocca.** ⚠️ Cio' che il rotolante rompe e' un'ALTRA affermazione — che il progetto non scrive da nessuna parte ma che il nome «disaster-SL -30%» suggerisce: che la perdita su un trade sia limitata al 30%. NON lo e' (§4b: fino a -60,6% BTC / -61,5% ETH dall'ingresso senza che lo stop scatti, nel 7-13% dei cicli a 30 giorni). E' una proprieta' di OGGI, indipendente da k, e la protezione vera del singolo trade sono le uscite software di TP01/SKH01. ⚠️ E c'e' un accoppiamento nuovo da registrare: l'accumulo di episodi e' funzione della CADENZA DEL CRON (1h: no · 4h: BTC 2 · 24h: ETH 5 con 2 in 30 giorni). Oggi la cadenza e' misurata sana, ma nessun sorvegliante la controlla contro questa conseguenza, e il docstring di `book_execute` ne prescrive una (~230 min) che cade nella riga peggiore. Non e' un'azione richiesta qui: e' il parametro da rileggere prima del gradino, insieme a G2.""") def main(): print(H) print(" SL-ANCHOR — il disaster-SL e' un PAVIMENTO o un -30% ROTOLANTE?") print(" Nodo aperto da docs/research/SPEC-scale-key.md §4.5 · SOLA LETTURA · nessun ordine") print(H) soglie = attesa() info = misura_log(soglie) if "--venue" in sys.argv[1:]: riscontro_venue(info) else: print("\n (riscontro sul venue non richiesto: aggiungere --venue per 2 letture di open_orders)") out = conseguenza() bounded() costo(out) print("\n" + H) print(" VERDETTO — il disaster-SL e' ROTOLANTE: MISURATO, non dedotto.") print(" · 3 osservazioni di produzione con bracket ricostruito a POSIZIONE FERMA e 0 ordini;") print(" · i salti (+5,74 / +5,81 / +5,38%) cadono appena sopra la soglia +5,263% predetta;") print(" · il venue conferma: il bracket vivo e' piu' GIOVANE della posizione che protegge.") print(" La conseguenza temuta — episodi che si accumulano — e' REFUTATA alla cadenza che gira:") print(" il 37,5% resta il costo di un episodio singolo e il margine 1,33x di G6 e' quello vero.") print(H) if __name__ == "__main__": main()