Files
PythagorasGoal/scripts/research/r0823_sl_anchor.py
T
Adriano Dal Pastro 1279ea605a revisione 02/09, seconda tornata: il classificatore dei movimenti era cieco ~23 ore al giorno
Il feed 1h certificato si ferma alle 00:00: per le letture successive `asof` dava la stessa
barra a t0 e t1, mercato "fermo" = 0, e qualunque calo >=10% del giorno sarebbe stato un
"movimento" scorporato come prelievo (giornale dal 25/08, report da oggi). Con feed assente
tutto era "ambiguo" e il report stampava e1/e0-1 sotto l'etichetta TWR.

- journal.movimenti_capitale: stato "mercato non misurabile" (feed fermo/assente/barra
  mancante) => ambiguo con la ragione; leva_tetto = min(frac x n x scala, LEVA_LORDA_MAX) (P1).
- journal.rendimento_twr: salto non classificabile => twr E trading None con motivo.
- journal.pnl_giorno CHIAMA rendimento_twr (prima rifaceva cum - certi: "entrambi la chiamano"
  era falso); la pagina stampa il TWR; analista qualifica il cumulato "di cui versati".
- trades_db --report: la classe di ogni salto con la sua misura (mercato max a leva piena).
- book_execute docstring: banda dello stop -33,5%/-26,5% (era invertita), "non costa ordini"
  -> micro-ordini di ri-taglia (22/48, C2), latenza <=1h solo con feed fresca, conteggi di
  r0823 al posto di "raddoppia" (2 contro 1 e' rotolante vs pavimento sulla riga 4h; la
  peggiore misurata e' 24h).
- test_book_cadenza: LTF_MIN importato da skyhook; tutte le righe attive della crontab (una
  sola); tre stati dello skip (col progetto ma senza cron_book => ROSSO); slot di release
  testato con venue_probe (il :07 come controllo positivo); parser regolare, passo > 0.
- r0823_sl_anchor: guardia sul :47 (era sul vecchio :07), prosa al passato; cron_chain.sh idem.
- CLAUDE.md: §5.7 frase invertita corretta, §5.14 limite vero (non l'artefatto della fixture),
  §2 ora della LETTURA (13:47Z), nuovo debito §5.15 (USDE fuori dal bound di mercato).
- diari: tempi "scritto" corretti coi commit; sezione "Seconda tornata".

Test +11 (858 verdi).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:33:13 +00:00

536 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 :45-:50 (cron_book, al :47 dal 25/08) 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 45 <= m <= 50 or 24 <= m <= 30: # cron_book al :47 dal 2026-08-25 (era :07), cron_chain :25
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:47) / 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.
· ⚠️ Aggancio operativo (RIPARATO il 2026-09-02, `tests/test_book_cadenza.py`): fino a quel
giorno il docstring di `scripts/live/book_execute.py` prescriveva «ogni ~230 minuti» (la
griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giri in 1442 ore).
Chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro dalla riga 1h
(rotolante: 0 scatti) alla riga 4h (2 scatti del rotolante contro 1 del pavimento).
(*) ⚠️ 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,
e dal 2026-09-02 `tests/test_book_cadenza.py` tiene d'accordo docstring, cron_book.sh e crontab
(il docstring ne prescriveva una, ~230 min, nella riga 4h). Resta 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()