research(wave-0822): SL-ANCHOR — il nodo sciolto dal LOG (la SPEC lo diceva non misurabile); rotolante ma il timore e' refutato, e il docstring della cadenza e' una trappola

This commit is contained in:
Adriano Dal Pastro
2026-08-23 00:37:46 +00:00
parent 0f64eb4720
commit 9973b04bc1
2 changed files with 602 additions and 0 deletions
+68
View File
@@ -705,6 +705,7 @@ stessi hanno nominato senza poterli eseguire**. Stesso contratto di consegna, st
| 37 | GATE-C | l'ultima gamba del gate funded + il biglietto come opzione | **PASS 2/2** — gate senza date; **P(≥50/g) 4,4% → 2,6%** | | 37 | GATE-C | l'ultima gamba del gate funded + il biglietto come opzione | **PASS 2/2** — gate senza date; **P(≥50/g) 4,4% → 2,6%** |
| 38 | SCALE-SPEC | specificare il knob di leva che non esiste | **LEAD** — specificata, **1 nodo irrisolto** sul disaster-SL | | 38 | SCALE-SPEC | specificare il knob di leva che non esiste | **LEAD** — specificata, **1 nodo irrisolto** sul disaster-SL |
| 39 | FUNDING-AVOID | evitare il funding esprimendo il libro sui datati | **SCARTATO** sui datati · **LEAD** sullo **spot** | | 39 | FUNDING-AVOID | evitare il funding esprimendo il libro sui datati | **SCARTATO** sui datati · **LEAD** sullo **spot** |
| 40 | SL-ANCHOR | il nodo che bloccava il gradino di leva | **SCIOLTO** — rotolante, ma il timore e' refutato |
| 22 | MAKER | l'esecuzione passiva e' una fonte di ritorno, al netto del costo di non essere eseguiti? | **SCARTATO** — il segno dipende da `<` contro `<=` | | 22 | MAKER | l'esecuzione passiva e' una fonte di ritorno, al netto del costo di non essere eseguiti? | **SCARTATO** — il segno dipende da `<` contro `<=` |
| 23 | BOCPD | un rilevatore di cambio di regime vero batte il miglior lookback COSTANTE? | **SCARTATO** — filone chiuso definitivamente | | 23 | BOCPD | un rilevatore di cambio di regime vero batte il miglior lookback COSTANTE? | **SCARTATO** — filone chiuso definitivamente |
| 24 | CRITICO | cosa NON e' stato misurato, e quale singola misura mancante vale di piu' | **3 correzioni all'ondata**, 1 regola mia ritirata | | 24 | CRITICO | cosa NON e' stato misurato, e quale singola misura mancante vale di piu' | **3 correzioni all'ondata**, 1 regola mia ritirata |
@@ -1982,3 +1983,70 @@ risparmio** — se i derivati sono `c-quater` (26%) e lo spot `c-sexies` (33%),
(i 0,47 punti risparmiati si pagano con 0,034 di ShHOLD, negativo a 0/23 offset, e il gate iso-vol (i 0,47 punti risparmiati si pagano con 0,034 di ShHOLD, negativo a 0/23 offset, e il gate iso-vol
fallisce). **SCARTATO** sui datati, **LEAD** sullo spot per la sola gamba TP01 — subordinato alla fallisce). **SCARTATO** sui datati, **LEAD** sullo spot per la sola gamba TP01 — subordinato alla
domanda fiscale, che va posta al commercialista **prima** di qualunque prova. domanda fiscale, che va posta al commercialista **prima** di qualunque prova.
---
## 40 — SL-ANCHOR (il nodo del disaster-SL, sciolto)
`scripts/research/r0823_sl_anchor.py` (**2,0 s**, sola lettura). **42 celle**, nessuna riduzione per
budget. ✅ **Sola lettura provata**: `git status` mostra solo questo file; il ramo venue fa **2 sole**
`get_open_orders` e si astiene nei minuti :05-:10 e :24-:30.
🚨 **La dichiarazione di non-misurabilita' della SPEC era FALSA: il log bastava.** Ieri notte
`SPEC-scale-key.md` scriveva *"letto nel codice, NON misurato — il log non lo permette (servirebbe
`pos_prima`): non ricostruibile a posteriori"*. **Terza conferma della regola del 26/07: un blocco
dichiarato e' un'IPOTESI, non un fatto.**
📌 **L'agente ha scritto l'attesa PRIMA di guardare, derivandola dal codice — e ha predetto le soglie
esatte:** rotolante e **simmetrico**, con innesco a **+5,263%** (salita) / **4,762%** (discesa),
perche' il fattore 0,70 si semplifica e la condizione `|stpwant|/want ≥ 0,05` diventa una condizione
**sul mark**, non sullo stop.
**Misura sul log di produzione** (1443 giri, 60,1 giorni, 2840 righe-asset): i 15 `placed` si
scompongono in **5 prime aperture + 7 ribilanci + 3 A POSIZIONE FERMA**. Le tre — 21/08 00:07 BTC,
06:07 ETH, 22:07 BTC — hanno azione `HOLD (a target)`, **0 ordini**, e **trigger spostato**: salti
**+5,74% / +5,81% / +5,38%**, tutti **appena sopra il +5,263% predetto**.
**Conferma dal venue:** la posizione BTC e' aperta dal **19/08 15:07**, l'ordine di stop sul book e'
stato creato il **22/08 01:07****l'ordine e' 2,5 giorni piu' giovane della posizione che protegge.**
🚨 **MA LA CONSEGUENZA CHE BLOCCAVA IL GRADINO E' REFUTATA, e va nel verso OPPOSTO al timore:** uno
stop che segue il prezzo **si allontana dal mercato**, quindi scatta **meno**. Episodi su storia reale
(caso peggiore: sempre long, rientro immediato): a **1h** — la cadenza che gira — BTC `roll` **0**
contro `floor` 1; ETH `roll` **1** contro 1; **mai piu' di 1 in 30 giorni**. Caduta massima storica
dentro un giro: BTC 1h **23,17%**, ETH **30,35%**, contro i ~26,5-30% che servirebbero.
📌 **E nell'outage — l'unico scenario per cui il bracket esiste — il ri-ancoraggio e' SPENTO per
costruzione**: nessun giro di cron, nessun ri-piazzamento ⇒ **un episodio solo**.
**⇒ Il 37,5% resta il costo di un episodio singolo, il margine 1,33× di WORST-DAY e' quello vero, e la
sua gamba REGGE.**
**Due cose da registrare, entrambe INDIPENDENTI da k:**
1. ⚠️ **Il nome inganna: "disaster-SL 30%" suggerisce un massimo di perdita per trade che NON
ESISTE.** Su cicli a ingresso proprio, il rotolante lascia passare perdite oltre 30% **senza
scattare** nel **0,03% (24h) / 0,96-2,84% (7g) / 7,22-13,33% (30g)** dei cicli, e il peggior DD
dall'ingresso e' **60,6% BTC / 61,5% ETH** contro 38,3%/45,4% del pavimento.
2. 🚨 **L'accumulo e' funzione della CADENZA DEL CRON, non della leva — e c'e' una trappola viva in
produzione:** il docstring di `book_execute.py` prescrive *"ogni ~230 minuti"* mentre **il cron gira
ogni ora**. Chi "correggesse" la cadenza verso il docstring sposterebbe il libro sulla riga **4h**,
**dove BTC raddoppia gli scatti** (`roll` 2 contro 1). A 24h ETH arriva a 5, con 2 in 30 giorni.
⚠️ **Trappola n.10 del brief catturata in sessione:** la colonna "perdita dall'ingresso" della prima
stesura era un **artefatto** (posizione aperta dal 2018 ⇒ era il DD di un buy&hold pluriennale) →
dichiarata e **rifatta con cicli a ingresso proprio**. E una prima versione della simulazione e' stata
**scartata** perche' il rientro avveniva fuori dal giro di cron (irrealistico).
**Controllo positivo superato** (obbligatorio): lo stesso esercizio applicato a un'implementazione
**a pavimento** risponde `ok` → l'esercizio **distingue** le due implementazioni, non passa e basta.
**Guardia anti-rete superata**: la sottoclasse che esercita il vero `ensure_disaster_sl` ha `_post`
che **solleva**, e l'esercizio e' riuscito ⇒ **nessuna chiamata di rete e' partita da quel percorso.**
⚠️ **Limite dichiarato:** **0 ri-ancoraggi verso il BASSO osservati su 3** — i 2 mesi di vita del libro
sono **un solo regime** (BTC da ~$66,5k a ~$78,6k) e non c'e' mai stato un calo del mark >4,76% con
posizione aperta. **La simmetria e' un fatto di CODICE** (un solo `abs()`, esercitato in entrambi i
versi), **non un fatto OSSERVATO**: la prima osservazione utile arriva al primo ribasso >5% con
posizione aperta.
📌 **Nota di metodo:** la storia degli ordini **non e' raggiungibile dal percorso del conto** — il
gateway `cerbero-mcp` espone 32 tool Deribit e **nessuno** e' `get_order_history_by_currency`. Le
chiavi dirette appartengono a un altro progetto e **non sono state usate**: sarebbe stato inventare un
percorso, *e la domanda non ne aveva bisogno.*
**VERDETTO: `IL DISASTER-SL E' ROTOLANTE (costo vero 37,5% contro 37,5% — un episodio solo alla cadenza
di produzione; sale a ~75% solo con cron a 24h, che non e' la configurazione che gira)`.**
+534
View File
@@ -0,0 +1,534 @@
"""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()