GATE SCALA-01: la chiave di scala esiste, ed e' INERTE (SPEC §8 punti 1-4)

Chiude il debito che CLAUDE.md dichiarava da settimane: la regola "ogni cambio
di scala passa dal cap di config, non da target_vol" NON era implementabile
perche' una chiave di scala non esisteva — qualunque cambio sarebbe finito su
WEIGHT/W_TP01/W_SKH, cioe' codice su un percorso con soldi veri e per giunta
nel posto sbagliato (W_TP01/W_SKH sono il RAPPORTO 75/25, non la taglia).

Specifica gia' scritta in docs/research/SPEC-scale-key.md (618 righe, 9
condizioni di gate, prototipo). Non ho progettato: ho eseguito i punti 1-4.

🚨 config/live.json NON E' STATO TOCCATO. La chiave e' assente, vale 1,00, e
T7 dimostra bit-exact che il libro e' quello di ieri (max|diff| = 0.0).
Verificato anche a runtime: book_execute in dry-run da' gli stessi target del
cron delle 15:47 (BTC $+355, ETH $+214).

LE QUATTRO DECISIONI CHE NON SONO DI COMODO
- La scala si applica DOPO il clamp. Prima, il cap se la mangerebbe proprio
  nei giorni di massima convinzione (a tp=1/sg=+1 il grezzo vale esattamente
  cap => k_eff tornerebbe a 1,00 a ogni k): sarebbe un cambio di FORMA
  travestito da cambio di taglia, e la curva g(k) con cui il gradino viene
  autorizzato non descriverebbe quel libro. Prezzo dichiarato: il cap diventa
  il tetto del libro UNITARIO, e la guardia sulla leva lorda va ricostruita.
- Il tetto e' sul PRODOTTO e sta nel CODICE. Sulla sola chiave lascerebbe
  aperta la porta accanto (frac 0,625 x scala 1,25 = 1,562x); in config
  sarebbe un lucchetto con la chiave attaccata. LEVA_LORDA_MAX 1,25 in
  src/live/book.py => il gradino a 1,50 richiede codice, quindi review.
- Fuori scaletta o fuori tetto = STOP, non clamp. book_execute si ferma, non
  invia, allerta (ScalaNonAutorizzata). E SCALA_LADDER (1,00 · 1,25) rende
  INESPRIMIBILE "solo un po'": 1,05 non e' prudente, e' fuori scaletta.
- La scala vive solo sul percorso fidato (equity illeggibile => 1,00), cosi'
  "il fallback non e' piu' permissivo" e' vero per costruzione. Ma la
  VALIDAZIONE avviene sempre: una config rotta non si nasconde dietro un giro
  in cui l'equity non era leggibile.

LA GUARDIA CHE MORDE PER PRIMA non e' il peggior giorno (k <= 3,49x) ma il
COSTO di un disaster-SL (k <= 1,67x, 2,1x piu' stringente): l'invariante
n_asset x frac x scala x disaster_sl_pct <= 0,50 scatta anche se qualcuno
allarga lo stop invece di alzare la scala.

TEST T1-T11 (tests/test_book_scale.py, 18 verdi). Il piu' importante e' T1b:
a k=1 l'implementazione simmetrica e quella asimmetrica danno lo STESSO
numero, quindi un test di simmetria scritto sul caso di default ha potenza
ZERO. T1b verifica che le due coincidano a k=1 (il rischio e' reale) e che
fuori da k=1 l'asserzione le SEPARI, con un'implementazione asimmetrica
scritta nel test apposta perche' fallisca.

SORVEGLIANTE scale_watch (cron_daily, 3 domande / 3 azioni / 3 stati, una
allerta per streak, marcatore scritto solo dopo invio riuscito — debito #2).
Riporta la frequenza del ramo di fallback, che sopra il 2% in 90 giorni
invaliderebbe la regola: misurata 0/1.676, coi 19 giri "paper capital"
(pre-finanziamento, dove il libro non invia) contati e dichiarati a parte.
Non puo' impedire la modifica: la rende visibile entro 24h e attribuibile.

CHIUDE il debito #5 di §5: T2/T3 sostituiscono il vecchio
test_leva_massima_da_config (che misurava frac x n_asset mentre la grandezza
vera e' frac x n_asset x scala), e T11 verifica che sia rimasto cancellato.

NON FATTO, deliberato: la chiave in config (punto 2 lo vieta), GATE SCALA-01
(A2 richiede >=30 giorni a 1,00 col sorvegliante attivo — "l'unico modo di
scoprire che il sorvegliante e' rotto mentre la leva e' ancora 1,00"),
r0726_fee_sensitivity rifatto (A7: serve solo al gradino; a 1,25x una
liquidazione costerebbe 1,25% non 1,00%, e ereditarlo sarebbe l'errore).

Nessun ordine. Suite: 825 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-09-01 17:44:02 +00:00
parent 05816c49f9
commit ec8478308f
9 changed files with 944 additions and 17 deletions
+93 -4
View File
@@ -63,6 +63,79 @@ EQUITY_WATERMARK = PROJECT_ROOT / "data" / "live" / "equity_seen.json"
# sicura, non il cap di config (che puo' essere stato alzato in previsione di un deposito).
CAP_UNKNOWN_USD = 300.0
# ---- CHIAVE DI SCALA (GATE SCALA-01) — spec: docs/research/SPEC-scale-key.md ------------------
# ⚠️ COSTANTI DI CODICE, NON DI CONFIG, e il motivo e' il meccanismo del cricchetto (SPEC §4.1b):
# un tetto che vive nello stesso file della chiave sarebbe "un lucchetto con la chiave attaccata".
# Portare il gradino a 1,50 richiede una modifica di CODICE -> una review -> un gate.
# Il tetto e' basso APPOSTA: non e' un'opinione sulla leva massima sopportabile (che da §4.5
# sarebbe 1,67x), e' il dispositivo che rende impossibile alzarla "solo un po'".
LEVA_LORDA_MAX = 1.25
# La scaletta esiste perche' una scala A GRADINI rende INESPRIMIBILE "solo un po'": 1,05 non e'
# un valore prudente, e' un valore fuori scaletta, e viene RIFIUTATO.
SCALA_LADDER = (1.00, 1.25)
# Invariante del disaster-SL (SPEC §4.5, la guardia che morde per PRIMA): il costo in equity di un
# episodio di disaster-SL e' `n_asset * frac * scala * disaster_sl_pct` e non deve superare mezza
# equity. Con frac 0,50 e sl 30% da' scala <= 1,67x, e scatta se qualcuno allarga il disaster-SL.
DISASTER_SL_BUDGET = 0.50
class ScalaNonAutorizzata(RuntimeError):
"""Config che dichiara una scala fuori scaletta o fuori tetto.
⚠️ E' un'ECCEZIONE e non un clamp, ed e' una scelta (SPEC §4.1c): tagliare silenziosamente al
tetto farebbe girare una config che DICHIARA un numero e un libro che ne ESEGUE un altro —
la stessa classe di difetto che questo progetto ha gia' pagato tre volte. Chi la incontra
ferma l'esecuzione e allerta; non "rende robusto" il lettore con un min().
"""
def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
cfg: dict | None = None) -> float:
"""Scala del libro (`book_scale_k` in config). 1.0 = il libro di oggi, bit-exact.
DUE COMPORTAMENTI, entrambi voluti:
(1) VALIDA SEMPRE, anche sul percorso degradato. Una config fuori scaletta o fuori tetto e'
un errore di configurazione, non una condizione di mercato: dev'essere scoperta al primo
giro, non nascosta dal fatto che oggi l'equity non era leggibile.
(2) POI applica la regola G3/G4: **la scala vive SOLO sul percorso fidato**. Se l'equity reale
non e' leggibile la scala e' 1,00 — *una scala e' una decisione di rischio presa conoscendo
il conto; quando non si sa quanto vale il conto, non si prende*. Cosi' "il fallback non e'
piu' permissivo" e' vero PER COSTRUZIONE, non per aritmetica.
Il tetto e' sul PRODOTTO `n_asset * frac * scala`, mai sulla sola chiave: con un tetto sulla
sola chiave, alzare `frac` da 0,50 a 0,625 con scala 1,25 darebbe 1,562x passando il controllo.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
raw = cfg.get("book_scale_k")
if raw is None:
return 1.0 # chiave assente = il libro di oggi
s = float(raw)
if not any(abs(s - x) < 1e-9 for x in SCALA_LADDER):
raise ScalaNonAutorizzata(
f"book_scale_k={s!r} non e' un gradino di SCALA_LADDER {SCALA_LADDER}: "
f"una scala a gradini rende inesprimibile 'solo un po''. Config: {CONFIG}")
frac = cfg.get("max_notional_per_asset_frac")
if frac is not None:
leva = len(ASSETS) * float(frac) * s
if leva > LEVA_LORDA_MAX + 1e-12:
raise ScalaNonAutorizzata(
f"leva lorda {leva:.4f}x = n_asset {len(ASSETS)} x frac {frac} x scala {s} "
f"supera LEVA_LORDA_MAX {LEVA_LORDA_MAX}x (tetto sul PRODOTTO, src/live/book.py). "
f"Alzarlo e' una modifica di CODICE, non di config.")
sl = float(cfg.get("disaster_sl_pct", 0.0))
costo = leva * sl
if costo > DISASTER_SL_BUDGET + 1e-12:
raise ScalaNonAutorizzata(
f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity "
f"(n_asset x frac {frac} x scala {s} x sl {sl}) > {100*DISASTER_SL_BUDGET:.0f}%: "
f"e' la guardia che morde per prima (SPEC-scale-key §4.5).")
if real_equity is None or eq_fallback:
return 1.0 # G3/G4: niente scala fuori dal percorso fidato
return s
def _read_watermark() -> float | None:
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
@@ -136,12 +209,22 @@ def _cap(equity: float | None = None, real_equity: float | None = None,
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
weight: float = WEIGHT) -> float:
weight: float = WEIGHT, scala: float = 1.0) -> float:
"""Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile.
Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25),
clampata al cap per-asset. Vedi formula nel docstring del modulo."""
clampata al cap per-asset. Vedi formula nel docstring del modulo.
⚠️ LA SCALA SI APPLICA **DOPO** IL CLAMP, e non e' una scelta di comodo (SPEC §3.2).
Applicandola PRIMA, il clamp la mangerebbe **proprio nei giorni di massima convinzione** (a
`tp=1, sg=+1` il grezzo vale esattamente `cap`, quindi k_eff tornerebbe a 1,00 a ogni k): il
risultato non sarebbe "il libro a leva k" ma un libro che alza i giorni piccoli e lascia fermi
i grandi — un cambio di FORMA travestito da cambio di taglia, che la curva g(k) con cui il
gradino viene autorizzato **non descrive**. DOPO il clamp, `k_eff == k` in ogni giorno.
Il prezzo, dichiarato: il cap smette di essere il tetto assoluto del nozionale e diventa il
tetto del libro UNITARIO; la guardia sulla leva lorda e' ricostruita in `_scala` (SPEC §4).
Con `scala=1.0` (chiave assente) questa funzione e' bit-exact quella di prima."""
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
return max(-cap, min(cap, raw))
return scala * max(-cap, min(cap, raw))
def _skh_sign(state) -> int:
@@ -192,6 +275,9 @@ def book_report(offline: bool = False, equity_override: float | None = None,
# fallback in OGNI ordine di eventi (vedi _cap).
equity_jump = write_equity_watermark(sh.get("real_equity"))
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
# La scala VALIDA sempre e si applica solo sul percorso fidato: se la config e' fuori scaletta
# o fuori tetto, `_scala` SOLLEVA e book_execute si ferma senza inviare (SPEC §4.1c).
scala = _scala(real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
load5m = None
feed_ages: dict[str, float | None] = {}
feed_errors: dict[str, str] = {}
@@ -226,7 +312,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
tp_frac = float(a_rec["target"])
st = skh.get(a, "flat")
sign = _skh_sign(st)
net = book_net_target(tp_frac, sign, equity, cap)
net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
cur = float(a_rec["position_usd"])
mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
@@ -243,6 +329,9 @@ def book_report(offline: bool = False, equity_override: float | None = None,
last_data=sh["last_data"], online=sh["online"],
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
# La scala EFFETTIVA del giro (1.0 = libro di oggi). Esposta perche' un parametro che muove
# il nozionale e non compare nel report e' la forma esatta di un difetto gia' pagato.
scala=scala, leva_lorda_max=LEVA_LORDA_MAX,
assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"),
eq_fallback=sh.get("eq_fallback"),
# Salto di equity fra due letture consecutive oltre EQUITY_JUMP_ALERT: dict o None.
+248
View File
@@ -0,0 +1,248 @@
"""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