1279ea605a
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
536 lines
32 KiB
Python
536 lines
32 KiB
Python
"""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()
|