"""scale_watch — il sorvegliante della CHIAVE DI SCALA del libro (SPEC-scale-key §6.4). PERCHE' ESISTE. Una chiave di scala e' esattamente il tipo di parametro che si alza "solo un po'" dopo un mese buono: e' un numero, sta in un file di config, non richiede di capire niente per cambiarlo, e il suo effetto e' immediato e piacevole. Il progetto ha guardie contro questa deriva su ogni altro asse (`weights_tilt_null` per i pesi, i gate pre-registrati per i candidati, `select_cell_insample` per le griglie) e NESSUNA per la scala — non per dimenticanza, per aritmetica: **lo Sharpe e' invariante alla scala**, quindi `deflated_sharpe` e `marginal_vs_tp01` leggono un numero che non cambia. La scala si vede solo su crescita e rovina. ⚠️ CIO' CHE QUESTO SORVEGLIANTE NON PUO' FARE: impedire la modifica. Chi ha accesso al file puo' scriverci dentro. Rende la modifica **visibile entro 24 ore** e **attribuibile**. E' la stessa onesta' di `venue_watch`: non protegge il saldo, compra tempo. TRE DOMANDE, e sono davvero tre perche' hanno TRE AZIONI DIVERSE e nessuna e' deducibile dalle altre (§6.4): D1 la config dichiara una scala che il GIORNALE non ha mai autorizzato? -> ricostruire come ci si e' arrivati, e scendere a k0. D2 il criterio che autorizzo' la scala CORRENTE passa ancora oggi? -> scendere di un gradino. D3 il PRODOTTO n_asset x frac x scala ha superato LEVA_LORDA_MAX? -> bloccare l'esecuzione. TRE STATI, non due: OK / ALLARME / NON MISURABILE. *"Non vedo" non e' "va tutto bene"* — lezione di `paper_dvolspread` e `venue_watch`. P1: ogni bersaglio e' IMPORTATO da `src.live.book`, mai ridichiarato qui. Un sorvegliante puntato su una configurazione diversa da quella che gira passa sempre e non sta controllando niente (5 occorrenze). """ from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path import pandas as pd # P1 — i bersagli si DERIVANO dal codice sorvegliato. Non riscrivere questi valori qui. from src.live.book import ( ASSETS, CONFIG, DISASTER_SL_BUDGET, LEVA_LORDA_MAX, SCALA_LADDER, ScalaNonAutorizzata, _scala, ) PROJECT_ROOT = Path(__file__).resolve().parents[2] HISTORY = PROJECT_ROOT / "data" / "live" / "scale_history.jsonl" STATE_PATH = PROJECT_ROOT / "data" / "live" / "scale_watch_state.json" CRON_LOG = PROJECT_ROOT / "logs" / "cron_book.log" OK, ALLARME, NON_MISURABILE = "OK", "ALLARME", "NON MISURABILE" # Soglia DICHIARATA PRIMA di vedere il caso (SPEC §4.3): se il ramo di fallback (online ma equity # illeggibile) supera il 2% dei giri in 90 giorni, il libro girerebbe a una leva MISTA e la regola # "scala solo sul percorso fidato" va ridiscussa — perche' la curva g(k) smetterebbe di descriverlo. FALLBACK_MAX_FRAC = 0.02 # Un gate scaduto non e' un gate: oltre questa eta' il criterio va rimisurato, non ereditato. GATE_MAX_AGE_GIORNI = 180 @dataclass class Stato: """Streak per domanda: si allerta UNA VOLTA per streak, non a ogni giro (P14/P9).""" streak: dict = field(default_factory=dict) detto: dict = field(default_factory=dict) def load_state(path: Path = STATE_PATH) -> Stato: try: d = json.loads(path.read_text()) return Stato(streak=d.get("streak", {}), detto=d.get("detto", {})) except Exception: return Stato() def save_state(st: Stato, path: Path = STATE_PATH) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"streak": st.streak, "detto": st.detto}, indent=1)) def leggi_giornale(path: Path = HISTORY) -> list[dict]: """Giornale append-only delle scale autorizzate. Non ricostruibile: sta nel backup.""" if not path.exists(): return [] out = [] for riga in path.read_text().splitlines(): riga = riga.strip() if riga: try: out.append(json.loads(riga)) except json.JSONDecodeError: continue return out def frequenza_fallback(log: Path = CRON_LOG) -> dict: """Quota di giri in cui l'equity reale NON era leggibile (SPEC §4.3). ⚠️ Distingue due cose che il log confonde in una riga sola: i giri **pre-finanziamento** ("paper capital") non sono il ramo che conta — li' il libro non e' finanziato e non manda ordini. Il ramo che invalida la regola e' *online ma equity illeggibile*, ed e' quello che farebbe girare il libro a una leva mista. """ if not log.exists(): return dict(stato=NON_MISURABILE, motivo=f"log assente: {log}") testo = log.read_text(errors="replace") giri = testo.count("cron_book =====") senza = testo.count("\n conto: ") paper = testo.count("conto: paper capital") veri = senza - paper # online, ma equity non leggibile if giri == 0: return dict(stato=NON_MISURABILE, motivo="nessun giro nel log") q = veri / giri return dict(stato=(ALLARME if q > FALLBACK_MAX_FRAC else OK), giri=giri, senza_equity=senza, paper=paper, fallback_veri=veri, quota=q, soglia=FALLBACK_MAX_FRAC) def osserva(cfg: dict | None = None, giornale: list[dict] | None = None) -> dict: """Le tre domande, ciascuna col suo stato e la sua azione. Nessun invio, nessuno stato scritto.""" if cfg is None: cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {} if giornale is None: giornale = leggi_giornale() dichiarata = cfg.get("book_scale_k") scala = 1.0 if dichiarata is None else float(dichiarata) frac = cfg.get("max_notional_per_asset_frac") sl = float(cfg.get("disaster_sl_pct", 0.0)) dom = {} # --- D1: la config dichiara una scala che il giornale non ha mai autorizzato? --------------- autorizzate = [r for r in giornale if isinstance(r.get("scala"), (int, float))] if not autorizzate: dom["D1"] = dict(stato=(OK if scala == 1.0 else ALLARME), letto=scala, motivo=("giornale vuoto e scala 1,00: e' il default, nulla da autorizzare" if scala == 1.0 else f"config dichiara {scala}x e il giornale e' VUOTO: nessuna " f"autorizzazione esiste"), azione="ricostruire come ci si e' arrivati; riportare la scala a 1,00") else: ultima = autorizzate[-1] ok = abs(float(ultima["scala"]) - scala) < 1e-9 dom["D1"] = dict(stato=(OK if ok else ALLARME), letto=scala, giornale=float(ultima["scala"]), dal=ultima.get("dal"), motivo=("config e giornale concordi" if ok else f"config dichiara {scala}x, l'ultima riga di giornale autorizza " f"{ultima['scala']}x (dal {ultima.get('dal')})"), azione=("nessuna" if ok else "ricostruire come ci si e' arrivati; scendere alla scala di giornale")) # --- D2: il criterio che autorizzo' la scala corrente passa ancora oggi? ------------------- if scala == 1.0: dom["D2"] = dict(stato=OK, letto=scala, motivo="scala 1,00: e' k0, non c'e' un gradino da ri-autorizzare", azione="nessuna") else: rec = next((r for r in reversed(autorizzate) if abs(float(r["scala"]) - scala) < 1e-9), None) eta = None if rec and rec.get("dal"): try: eta = (pd.Timestamp.now(tz="UTC") - pd.Timestamp(rec["dal"])).days except Exception: eta = None if rec is None or eta is None: dom["D2"] = dict(stato=NON_MISURABILE, letto=scala, motivo="nessuna riga di giornale databile per la scala corrente", azione="rieseguire GATE SCALA-01 e registrarne l'esito") elif eta > GATE_MAX_AGE_GIORNI: dom["D2"] = dict(stato=ALLARME, letto=scala, eta_giorni=eta, motivo=f"il gate che autorizzo' {scala}x ha {eta} giorni " f"(>{GATE_MAX_AGE_GIORNI}): un gate scaduto non e' un gate", azione="scendere di un gradino, o rieseguire GATE SCALA-01 oggi") else: dom["D2"] = dict(stato=OK, letto=scala, eta_giorni=eta, motivo=f"gate registrato {eta} giorni fa, dentro i {GATE_MAX_AGE_GIORNI}", azione="nessuna") # --- D3: il PRODOTTO ha superato il tetto? ------------------------------------------------- if frac is None: dom["D3"] = dict(stato=NON_MISURABILE, motivo="max_notional_per_asset_frac assente in config", azione="nessuna: il cap e' fisso, la leva non e' derivabile da qui") else: leva = len(ASSETS) * float(frac) * scala costo = leva * sl sfora = leva > LEVA_LORDA_MAX + 1e-12 sfora_sl = costo > DISASTER_SL_BUDGET + 1e-12 dom["D3"] = dict(stato=(ALLARME if (sfora or sfora_sl) else OK), leva_lorda=leva, tetto=LEVA_LORDA_MAX, costo_disaster_sl=costo, budget=DISASTER_SL_BUDGET, frac=float(frac), scala=scala, sl=sl, motivo=(f"leva lorda {leva:.4f}x = {len(ASSETS)} asset x frac {frac} x " f"scala {scala}" + (f" SUPERA il tetto {LEVA_LORDA_MAX}x" if sfora else f", sotto il tetto {LEVA_LORDA_MAX}x") + (f"; un disaster-SL costerebbe {100*costo:.1f}% > " f"{100*DISASTER_SL_BUDGET:.0f}%" if sfora_sl else "")), azione=("bloccare l'esecuzione" if (sfora or sfora_sl) else "nessuna")) # --- la config e' comunque leggibile dal codice di produzione? ----------------------------- try: _scala(real_equity=1.0, eq_fallback=None, cfg=cfg) lettore = dict(stato=OK, motivo="src.live.book._scala accetta la config corrente") except ScalaNonAutorizzata as e: lettore = dict(stato=ALLARME, motivo=str(e), azione="book_execute si fermera' al prossimo giro senza inviare ordini") stati = [d["stato"] for d in dom.values()] + [lettore["stato"]] globale = (ALLARME if ALLARME in stati else (NON_MISURABILE if NON_MISURABILE in stati else OK)) return dict(stato=globale, scala_dichiarata=scala, domande=dom, lettore=lettore, fallback=frequenza_fallback(), ladder=list(SCALA_LADDER), tetto=LEVA_LORDA_MAX, ts=pd.Timestamp.now(tz="UTC").isoformat()) def run_once(state_path: Path = STATE_PATH, sender=None, cfg: dict | None = None, giornale: list[dict] | None = None) -> dict: """Un giro: osserva, avanza le streak, INVIA (se gli si da' un sender), salva. ⚠️ Il marcatore "gia' detto" si scrive SOLO DOPO un invio riuscito (debito #2 del 28/08): un 🚨 perso prima era perso per l'episodio intero. Senza `sender` non si marca nulla e il report lo dice. """ rep = osserva(cfg=cfg, giornale=giornale) st = load_state(state_path) da_dire = [] for nome, d in list(rep["domande"].items()) + [("fallback", rep["fallback"])]: if d.get("stato") == ALLARME: st.streak[nome] = int(st.streak.get(nome, 0)) + 1 if not st.detto.get(nome): da_dire.append((nome, d)) else: st.streak[nome] = 0 st.detto[nome] = False rep["da_dire"] = [n for n, _ in da_dire] rep["streak"] = dict(st.streak) if not da_dire: rep["invio"] = "niente da dire" elif sender is None: rep["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano intatti, " "cosi' il prossimo giro con un trasporto vivo lo dira'") else: righe = [f"🚨 SCALA DEL LIBRO — {n}: {d['motivo']}\n → {d.get('azione', '')}" for n, d in da_dire] ok, motivo = sender("\n".join(righe)) rep["invio"] = f"{'inviato' if ok else 'FALLITO'}: {motivo}" if ok: for n, _ in da_dire: st.detto[n] = True save_state(st, state_path) return rep