From ec8478308f0e05d1e849a563eb518af538bbb8d6 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 1 Sep 2026 17:44:02 +0000 Subject: [PATCH] =?UTF-8?q?GATE=20SCALA-01:=20la=20chiave=20di=20scala=20e?= =?UTF-8?q?siste,=20ed=20e'=20INERTE=20(SPEC=20=C2=A78=20punti=201-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 36 ++- docs/diary/2026-09-01b-chiave-di-scala.md | 166 ++++++++++++ docs/research/SPEC-scale-key.md | 14 +- scripts/cron_daily.sh | 6 + scripts/live/book_execute.py | 27 +- scripts/live/scale_watch.py | 62 +++++ src/live/book.py | 97 ++++++- src/live/scale_watch.py | 248 ++++++++++++++++++ tests/test_book_scale.py | 305 ++++++++++++++++++++++ 9 files changed, 944 insertions(+), 17 deletions(-) create mode 100644 docs/diary/2026-09-01b-chiave-di-scala.md create mode 100644 scripts/live/scale_watch.py create mode 100644 src/live/scale_watch.py create mode 100644 tests/test_book_scale.py diff --git a/CLAUDE.md b/CLAUDE.md index cf6c921..3f94295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,13 +160,28 @@ CLAUDE.md", la risposta e' la colonna *cosa la riapre*. | **Peso 75/25 confermato 3 volte** (24/07, 26/07, 24/07-follow-up) | 26/07 | — | `weights_tilt_null` superato, che finora **fallisce** | | **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo | | **GTAA01: decisione tenere/bloccare RINVIATA a $15k di book** — sotto quella soglia lo sleeve **non e' deployabile** (`GTAA_MIN_CAPITAL` $3.000 allocati al peso 20%), quindi finora e' manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio **+0,095 / +0,124** di Sharpe secondo la fase: **reale e sempre positivo**, e delle stesse dimensioni dello spread fra le baseline (0,12) | 28/08 | «GTAA01 non contribuisce» — la misura dice il contrario | **il book a $15k**, che il giornale sorveglia da solo (`journal.SOGLIE_CAPITALE`) | -| **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | costruire prima una **chiave di scala esplicita** in config **e** rifare `r0726_fee_sensitivity` | +| **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | ✅ la **chiave di scala e' costruita** (01/09, inerte a 1,00): restano **GATE SCALA-01** (§4) e il rifacimento di `r0726_fee_sensitivity` al nuovo lordo — la sua conclusione *"liquidation fee 1% irrilevante"* era condizionata a lordo ≤1x, a 1,25x costerebbe **1,25%** | -⚠️ **La regola *"ogni cambio di scala passa dal cap di config, non da `target_vol`"* NON E' -IMPLEMENTABILE COME SCRITTA:** verificato che in `config/live.json` **non esiste una chiave di scala** -— servirebbe toccare `WEIGHT`/`W_TP01`/`W_SKH` in `src/live/book.py`, cioe' codice su un percorso con -soldi veri. Specifica pronta in `docs/research/SPEC-scale-key.md` (`GATE SCALA-01`, 9 condizioni, con -il **tetto sul PRODOTTO** `n_asset · frac · scala · disaster_sl_pct ≤ 0,50` — non sulla singola chiave). +✅ **La chiave di scala ESISTE dal 2026-09-01** (era il debito che rendeva la regola *"ogni cambio di +scala passa dal cap di config"* non implementabile). `book_scale_k`, letta da `src/live/book._scala` +e applicata **DOPO il clamp** in `book_net_target` — l'ordine non e' di comodo: applicandola PRIMA il +cap se la mangerebbe proprio nei giorni di massima convinzione (k_eff tornerebbe a 1,00 a ogni k), +cioe' sarebbe un cambio di **FORMA** travestito da cambio di taglia, che la curva `g(k)` con cui il +gradino viene autorizzato **non descrive**. 🚨 **La chiave e' INERTE: assente = 1,00 = il libro di +sempre, bit-exact** (T7, `max|diff| = 0.0`) — `config/live.json` **non e' stato toccato**. Guardie: +`LEVA_LORDA_MAX` 1,25 e `SCALA_LADDER` (1,00 · 1,25) sono **costanti di CODICE, non di config** (un +tetto nello stesso file della chiave sarebbe un lucchetto con la chiave attaccata: il gradino a 1,50 +richiede una modifica di codice, quindi una review); il tetto e' **sul PRODOTTO** +`n_asset · frac · scala` e l'invariante del disaster-SL e' `n_asset · frac · scala · disaster_sl_pct +≤ 0,50` (la guardia che morde per **prima**: da' scala ≤ 1,67x e scatta se qualcuno allarga lo stop); +una scala fuori scaletta o fuori tetto **NON viene tagliata** — `book_execute` si ferma, non invia e +allerta (`ScalaNonAutorizzata`); e **la scala vive solo sul percorso fidato** (equity reale +illeggibile ⇒ 1,00, cosi' "il fallback non e' piu' permissivo" e' vero per costruzione). +Sorvegliante `src/live/scale_watch.py` (giornaliero in `cron_daily`, 3 domande / 3 azioni / 3 stati, +una allerta per streak, marcatore scritto solo dopo invio riuscito), giornale append-only +`data/live/scale_history.jsonl`. Test **T1-T11 in `tests/test_book_scale.py` (18, tutti verdi)**. +📌 **Cio' che il sorvegliante NON puo' fare: impedire la modifica** — la rende visibile entro 24h e +attribuibile. Come `venue_watch`: non protegge il saldo, compra tempo. --- @@ -183,6 +198,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure. | **DVOLSPREAD** | kill 2026-10-24 se Sh<−0,50; decisione 2027-01-24 | (a) Sharpe>0 (b) marginale ADDS+robust_oos+insample_edge (c) **DSR ≥0,95** (d) `weights_tilt_null`; **veto** se barre attive <80% → si estende, non si decide | ✅ serie rigenerata il 26/08 (−4,41 a oggi, era −14,73 sulla serie rotta); il veto barre-attive ora conta barre VERE e la guardia PREMATURO sorveglia il monitor | | **GATE PROP-01** | chiuso 22-23/08 | (a) listino 13/13 ✅ (b) ancora SKH01 23/23 ✅ (c) economica+sopravvivenza 2/2 ✅ | **chiuso 3/3** — ma il numero operativo e' sceso 42% → 7,8% → 4,4% → 2,6% | | **GATE PREVDAY-01** | scritto 23/08 | 10 condizioni | **8/10**: la cella che GIRA non e' quella che la selezione onesta sceglie, e quella viva **fallisce il DSR** (0,905) | +| **GATE SCALA-01** (portare `book_scale_k` da 1,00 a 1,25) | non prima del **2026-10-01** | 9 condizioni **tutte necessarie** (`SPEC-scale-key` §6.2): A1 gradino di `SCALA_LADDER` · A2 k0 in produzione ≥180g col criterio passato ogni giorno · A3 **C1-C5 di WORST-DAY ricalcolati OGGI**, non ereditati · A4 invariante disaster-SL · A5 tetto sul prodotto · **A6 ANTI-RECENCY** (il criterio deve passare anche **escludendo gli ultimi 90 giorni**) · A7 `r0726_fee_sensitivity` rifatto · A8 la modifica e' **una riga di config + una di giornale, zero codice** · A9 T1-T11 verdi | ◐ **A8/A9 FATTI il 01/09** (chiave inerte + 18 test); **A2 non maturo** — la specifica impone ≥30 giorni a 1,00 col sorvegliante attivo *prima* di eseguire il gate: «l'unico modo di scoprire che il sorvegliante e' rotto mentre la leva e' ancora 1,00». 📌 **A6 oggi NON morde** (k ammesso ~3,5x contro un gradino di 1,25x): il vincolo che morde e' il **disaster-SL**. E' scritto ora **perche' non e' comodo per nessuno**, non quando servira' | | **`edge_watch`** (kill del book live) | continuo | **(A)** Sharpe rolling 36m < −0,5 → si riapre `weights_tilt_null`+DSR, il book **non si spegne da solo**; **(B)** in un anno con DD buy&hold >10%, il DD di TP01 deve restare <75% di quello | stato 27/07: Sharpe 36m +1,51, protezione **8/8** | 📌 **XSR01 sotto la lente RENDITA (25/08): il meccanismo vale ~0.** Mescolarne i rendimenti da' lo @@ -247,8 +263,11 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per esatto dipende dal codice ATECO. 5. ✅ **FATTO (2026-08-26):** `test_leva_massima_da_config...` cancellato con nota in `tests/test_fee_sensitivity.py` — misurava `frac · n_asset`, con una chiave di scala avrebbe - continuato a passare smettendo di controllare. La guardia giusta e' il tetto sul PRODOTTO di - GATE SCALA-01, da cablare nel codice che leggera' la chiave quando esistera'. + continuato a passare smettendo di controllare. ✅ **CHIUSO il 2026-09-01:** la chiave esiste e la + sostituzione e' cablata — **T2** (tetto sul PRODOTTO `n_asset · frac · scala ≤ LEVA_LORDA_MAX`) e + **T3** (invariante disaster-SL) in `tests/test_book_scale.py`, che importano i bersagli da + `src.live.book` invece di ridichiararli (P1). Il vecchio test resta **cancellato**, non rilassato, + e **T11 lo verifica**. 6. ⚠️ **TLT ha 13,5 anni di storia in meno** (parte 2016-02 invece del 2002) → **GTAA01 gira su CINQUE gambe prima del 2016**, e l'assente e' quella obbligazionaria. Non e' un fetch da rifare (IB ritorna 0 barre). ⇒ in-sample e hold-out di GTAA01 **non sono la stessa strategia**. @@ -501,6 +520,7 @@ src/portfolio/ → portfolio.py (combina N sleeve + weights_tilt_n src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS) src/live/ → book.py (esecutore netto TP01+SKH01), shadow.py, deribit.py, livefeed.py, venue_watch.py, monitor_health.py, notifier.py, + scale_watch.py (chiave di scala: 3 domande, 3 stati), tradesdb.py · journal.py · analista.py (libro di bordo) src/data/eq_splits.py, eq_crosscheck.py → guardie sul feed equity scripts/research/ → ricerca (r_*.py). Harness condiviso: alt/altlib.py diff --git a/docs/diary/2026-09-01b-chiave-di-scala.md b/docs/diary/2026-09-01b-chiave-di-scala.md new file mode 100644 index 0000000..622ab87 --- /dev/null +++ b/docs/diary/2026-09-01b-chiave-di-scala.md @@ -0,0 +1,166 @@ +# 2026-09-01 — La chiave di scala esiste, ed è inerte + +*Scritto il 2026-09-01. Implementazione dei punti 1-4 di `docs/research/SPEC-scale-key.md` §8.* +**Nessun ordine. `config/live.json` NON è stato toccato: il libro che gira è bit-exact quello di ieri.** + +## 0. Da dove nasce + +Il progetto aveva una regola — *«ogni cambio di scala passa dal cap di config, non da `target_vol`»* — +e CLAUDE.md la accompagnava da settimane con questa nota: + +> ⚠️ **NON È IMPLEMENTABILE COME SCRITTA:** in `config/live.json` non esiste una chiave di scala. + +Era un debito preciso: la regola prescriveva un percorso che non esisteva, quindi qualunque cambio +di scala sarebbe finito su `WEIGHT`/`W_TP01`/`W_SKH` in `src/live/book.py` — cioè 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: moltiplicarli romperebbe la parità coi pesi del backtest). + +La specifica era già scritta, 618 righe, con nove condizioni di gate e il prototipo che ne dimostra +la meccanica. **Non ho progettato niente: ho eseguito.** + +## 1. Il rischio che l'implementazione doveva rendere impossibile + +La §0 della specifica lo dice meglio di come lo direi io: + +> *Una chiave di scala è esattamente il tipo di parametro che si alza «solo un po'» dopo un mese +> buono. È un numero, sta in un file di config, non richiede di capire niente per cambiarlo, e il +> suo effetto è immediato e piacevole.* + +E il motivo per cui il progetto non aveva già una guardia non è dimenticanza, è **aritmetica**: +**lo Sharpe è invariante alla scala.** `deflated_sharpe` e `marginal_vs_tp01` leggono un numero che +a k=1,00 e a k=2,00 è identico. Non falliscono: **non vedono**. + +## 2. Cosa ho costruito + +| pezzo | dove | cosa fa | +|---|---|---| +| `book_scale_k` | `config/live.json` — **assente** | la chiave. Assente = 1,00 = libro di sempre | +| `_scala()` | `src/live/book.py` | legge, **valida sempre**, applica solo sul percorso fidato | +| `book_net_target(..., scala=)` | `src/live/book.py` | applica la scala **DOPO il clamp** | +| `LEVA_LORDA_MAX` 1,25 · `SCALA_LADDER` (1,00 · 1,25) · `DISASTER_SL_BUDGET` 0,50 | `src/live/book.py` | **costanti di CODICE** | +| `ScalaNonAutorizzata` | `src/live/book.py` | eccezione, **non** un clamp | +| riga «scala libro» + stop-e-allerta | `scripts/live/book_execute.py` | la scala è visibile a ogni giro | +| `scale_watch` | `src/live/` + `scripts/live/` + `cron_daily.sh` | 3 domande, 3 azioni, 3 stati | +| `scale_history.jsonl` | `data/live/` | giornale append-only (già nel backup) | +| T1-T11 | `tests/test_book_scale.py` | **18 test, tutti verdi** | + +### 2.1 Le quattro decisioni che non sono di comodo + +**(a) La scala si applica DOPO il clamp.** Applicandola prima, il cap se 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. E +allora la curva `g(k)` con cui il gradino viene autorizzato **non descriverebbe quel libro**. +*Autorizzare con una curva e implementarne un'altra è il modo più silenzioso di sbagliare.* +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 è ricostruita esplicitamente altrove. + +**(b) Il tetto è sul PRODOTTO, e sta nel CODICE.** Un tetto sulla sola chiave lascia aperta la porta +accanto: `frac` 0,625 × scala 1,25 = **1,562x**, che passerebbe. E un tetto in config sarebbe +modificabile dalla stessa mano, nello stesso file, nello stesso momento — *un lucchetto con la chiave +attaccata*. Con `LEVA_LORDA_MAX` in `src/live/book.py`, il gradino 1,25 → 1,50 richiede una modifica +di codice, quindi una review. **Il tetto è basso apposta: è il meccanismo del cricchetto.** + +**(c) Fuori scaletta o fuori tetto = STOP, non clamp.** `book_execute` si ferma, non invia, allerta. +Tagliare silenziosamente farebbe girare una config che *dichiara* un numero e un libro che ne +*esegue* un altro. E `SCALA_LADDER` esiste perché **una scala a gradini rende inesprimibile «solo un +po'»**: 1,05 non è un valore prudente, è un valore fuori scaletta, e viene rifiutato. + +**(d) La scala vive solo sul percorso fidato.** Equity reale illeggibile ⇒ scala 1,00. *Una scala è +una decisione di rischio presa conoscendo il conto; quando non si sa quanto vale il conto, non si +prende.* Così «il fallback non è più permissivo» è vero **per costruzione**, non per aritmetica. +⚠️ Ma la **validazione** avviene comunque, anche sul percorso degradato: una config rotta è un +errore di configurazione, non una condizione di mercato, e non deve nascondersi dietro un giro in +cui l'equity non era leggibile. + +### 2.2 La guardia che morde per prima, e non è quella che sembra + +Il disaster-SL è un movimento di **prezzo** (−30% sul mark), ma il suo impatto in equity è quel +movimento **moltiplicato per il lordo** — quindi cresce con k anche se `disaster_sl_pct` non cambia: + +- dal peggior giorno possibile: `k ≤ 3,49x` +- **dal costo di un disaster-SL: `k ≤ 1,67x`** ← **2,1× più stringente** + +Da qui l'invariante `n_asset · frac · scala · disaster_sl_pct ≤ 0,50`, che scatta anche se qualcuno +allarga lo stop invece di alzare la scala. + +## 3. Il test che conta più degli altri + +**T1b**, e la ragione per cui la specifica gli dedica un paragrafo: **a k=1 l'implementazione +simmetrica e quella asimmetrica danno lo stesso identico numero.** Un test di simmetria scritto sul +caso di default passa sempre e non controlla niente — potenza **zero**, la stessa firma dei test di +`book_live` scoperti senza potenza a libro flat. + +T1b quindi verifica due cose: *(a)* che a k=1 le due implementazioni siano davvero indistinguibili +(cioè che il rischio sia reale), e *(b)* che fuori da k=1 l'asserzione di T1 le **separi**. Nel test +c'è scritta un'implementazione asimmetrica apposta perché fallisca. + +Gli altri: T2/T3 i due tetti letti da config coi bersagli **importati** dalla produzione; T4 il +fallback mai più permessivo in ogni stato degradato; T5 il cap che morde ancora; T6 lo stop non +clampato (più T6b sul prodotto e T6c sul disaster-SL); **T7 l'inerzia bit-exact**; T8 il report che +la dichiara; T9 i bersagli derivati e non ridichiarati; T10 il **controllo positivo del +sorvegliante** (con una config non autorizzata deve parlare, con config e giornale concordi deve +tacere) più T10b sulla disciplina degli allarmi e T10c sui tre stati; T11 che il vecchio test sia +stato **sostituito, non rilassato**. + +## 4. Il sorvegliante, e cosa non può fare + +`scale_watch` gira in `cron_daily` e risponde a tre domande che hanno **tre azioni diverse**: + +| domanda | azione se 🚨 | +|---|---| +| la config dichiara una scala che il **giornale** non ha mai autorizzato? | ricostruire come ci si è arrivati, scendere a k0 | +| il criterio che autorizzò la scala **corrente** passa ancora oggi? | scendere di un gradino | +| il **prodotto** `frac · scala · n_asset` ha superato il tetto? | bloccare l'esecuzione | + +Tre stati (`OK` / `ALLARME` / **`NON MISURABILE`**), una allerta per streak, e il marcatore «già +detto» scritto **solo dopo un invio riuscito** (il debito #2 del 28/08: un 🚨 perso prima era perso +per l'episodio intero). + +Riporta anche la **frequenza del ramo di fallback**, che se superasse il 2% in 90 giorni +invaliderebbe la regola (d): il libro girerebbe a una leva **mista** e `g(k)` smetterebbe di +descriverlo. Misurata oggi: **0 giri su 1.676**. ⚠️ I 19 giri senza equity reale sono tutti +«paper capital», cioè **pre-finanziamento**, dove il libro non invia affatto — il sorvegliante li +conta e li dichiara separatamente invece di gonfiare la quota. + +🚨 **Ciò che il sorvegliante NON può fare: impedire la modifica.** Chi ha accesso al file può +scriverci dentro. Rende la modifica visibile entro 24 ore e attribuibile. È la stessa onestà di +`venue_watch`: non protegge il saldo, **compra tempo**. + +## 5. Verifica che il libro non è cambiato + +``` + sizing base : $2,052.22 | cap/asset $1026 | min $5 | disaster-SL -30% + scala libro : 1.00x (tetto 1.25x) | leva lorda max 1.000x dell'equity [chiave assente o 1,00 = libro invariato] + BTC TP +0.461 · SKH +0(flat) -> net $+355 | pos $+357 -> HOLD (a target) + ETH TP +0.278 · SKH +0(flat) -> net $+214 | pos $+212 -> HOLD (a target) +``` + +Gli stessi target del cron delle 15:47. **Suite: 825 passati** (807 + 18 nuovi). + +## 6. Cosa NON ho fatto, ed è deliberato + +**Non ho scritto la chiave in config.** Il punto 2 della checklist lo prescrive esplicitamente, e T7 +dimostra bit-exact che senza la chiave il libro è quello di prima. + +**Non ho eseguito GATE SCALA-01**, e non era possibile: A2 richiede che k0 sia in produzione con il +criterio passato ogni giorno, e il punto 5 impone **≥30 giorni a 1,00 col sorvegliante attivo prima** +di eseguire il gate. Cito la specifica perché la frase è il motivo: + +> *Il punto 5 non è burocrazia: è l'unico modo di scoprire che il sorvegliante è rotto mentre la +> leva è ancora 1,00.* + +**Non ho rifatto `r0726_fee_sensitivity`** (A7). Serve solo *se* e *quando* il gradino viene chiesto: +la sua conclusione «liquidation fee 1% irrilevante» era condizionata a lordo ≤1x, e a 1,25x una +liquidazione costerebbe **1,25%** dell'equity. Non è un problema, è un numero da ricalcolare — e +ereditarlo sarebbe l'errore. + +📌 **A6 (anti-recency) oggi non morde**: `k_ammesso ≈ 3,5x` contro un gradino di 1,25x, e il vincolo +che morde è il disaster-SL. È scritto adesso **perché adesso non è comodo per nessuno** — che è +l'unico momento in cui una regola del genere si può scrivere onestamente. + +## 7. Stato del gate + +`GATE SCALA-01` è ora in CLAUDE.md §4 con data **non prima del 2026-10-01**. A8 e A9 sono fatti; +A2 matura col tempo; A1/A4/A5 sono aritmetica già cablata; A3/A6/A7 si eseguono il giorno del gate, +sui dati di quel giorno, non su questi. diff --git a/docs/research/SPEC-scale-key.md b/docs/research/SPEC-scale-key.md index b9fc593..9cca649 100644 --- a/docs/research/SPEC-scale-key.md +++ b/docs/research/SPEC-scale-key.md @@ -1,8 +1,18 @@ # SPEC — la CHIAVE DI SCALA del libro live (`book_scale_k`) **Filone SCALE-SPEC, ondata 2026-08-22, branch `research/wave-0822`.** -**Questo documento e' una SPECIFICA. Non e' stata implementata: `src/`, `config/`, `scripts/live/`, -`scripts/cron_*.sh` e `tests/` non sono stati toccati.** Il prototipo che dimostra la meccanica e' +> ✅ **IMPLEMENTATA IL 2026-09-01 — punti 1-4 della checklist §8.** `src/live/book.py` +> (`book_scale_k`, `_scala`, `LEVA_LORDA_MAX`, `SCALA_LADDER`, `DISASTER_SL_BUDGET`, +> `ScalaNonAutorizzata`, `scala` nel report), `scripts/live/book_execute.py` (riga «scala libro» + +> stop-e-allerta), `src/live/scale_watch.py` + `scripts/live/scale_watch.py` in `cron_daily.sh`, +> `data/live/scale_history.jsonl`, `tests/test_book_scale.py` (T1-T11, **18 test verdi**). +> 🚨 **`config/live.json` NON e' stato toccato**, come prescrive il punto 2: la chiave e' assente, +> vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`). +> **Restano i punti 5-7:** ≥30 giorni a 1,00 col sorvegliante attivo, poi `GATE SCALA-01`, poi +> `r0726_fee_sensitivity` rifatto. Il punto 5 non e' burocrazia — e' l'unico modo di scoprire che il +> sorvegliante e' rotto mentre la leva e' ancora 1,00. + +**Questo documento era una SPECIFICA scritta prima dell'implementazione.** Il prototipo che dimostra la meccanica e' `scripts/research/r0822e_scale_proto.py` (isolato, non importato da niente, non scrive nulla; la sua sezione (0) verifica entrambe le cose a runtime invece di dichiararle). diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index abae383..0f2c686 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -41,6 +41,12 @@ mkdir -p logs # Criterio di sufficienza pre-registrato (>=40 coppie, IC95 <=0.12): avvisa da solo quando # il campione basta, invece di lasciare la misura appesa a un promemoria. uv run python scripts/live/vrp_f_watch.py --quiet || true + # La CHIAVE DI SCALA del libro: la config dichiara una scala che il giornale non ha autorizzato? + # Il criterio che la autorizzo' passa ancora? Il PRODOTTO frac x scala x n_asset ha superato il + # tetto? Tre domande, tre azioni diverse. Non impedisce la modifica: la rende visibile entro 24h + # e attribuibile — una chiave di scala e' il parametro che si alza "solo un po'" dopo un mese + # buono, e lo Sharpe (invariante alla scala) non lo vedrebbe mai. SPEC-scale-key §6.4. + uv run python scripts/live/scale_watch.py --quiet || true # I forward-monitor stanno registrando? Tre gate pre-registrati (27/09, 23/10, 24/10) si # decidono su queste serie: un monitor fermo produce silenzio, e il silenzio sembra uno zero. # Va DOPO tutti i monitor, altrimenti misura lo stato di ieri. diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index eea6a5d..217bf25 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -30,7 +30,7 @@ import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) -from src.live.book import book_report +from src.live.book import ScalaNonAutorizzata, book_report from src.live.execution import DeribitTrader from src.live.notifier import notify from src.live.venue_probe import diagnose, errori_dal_report @@ -78,7 +78,20 @@ def _run(): min_order = float(cfg["min_order_usd"]) sl_pct = float(cfg["disaster_sl_pct"]) - r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco) + try: + r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco) + except ScalaNonAutorizzata as e: + # G1(c): NON si taglia al tetto. Un clamp silenzioso farebbe girare una config che dichiara + # un numero e un libro che ne esegue un altro. Si ferma, non invia, e allerta. + msg = (f"🚨 SCALA NON AUTORIZZATA — book_execute FERMO, nessun ordine inviato.\n{e}\n" + f"Ripara: riporta `book_scale_k` a un gradino di SCALA_LADDER dentro il tetto, " + f"oppure rimuovi la chiave (assente = 1,00 = libro di oggi).") + print(msg) + try: + notify(msg) + except Exception as ne: # P3: l'errore si registra dove lo si ingoia + print(f" ⚠️ allerta NON inviata: {type(ne).__name__}: {ne}") + return equity = r["equity"] print("=" * 88) @@ -89,7 +102,15 @@ def _run(): print(f" modo : {mode}") print(f" gate : execution_enabled={enabled} | --execute={want_execute}") print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}") - print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%") + _sc = r.get("scala", 1.0) + _lordo = len(r["assets"]) * float(cfg.get("max_notional_per_asset_frac") or 0.0) * _sc + print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | " + f"min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%") + # La scala e' stampata SEMPRE, anche a 1,00: un parametro che muove il nozionale e non compare + # nel log e' invisibile proprio nel giro in cui e' cambiato (SPEC-scale-key §2.2). + print(f" scala libro : {_sc:.2f}x (tetto {r.get('leva_lorda_max', 1.25):.2f}x) | " + f"leva lorda max {_lordo:.3f}x dell'equity" + + ("" if _sc != 1.0 else " [chiave assente o 1,00 = libro invariato]")) print(f" ultima barra : {r['last_data']}\n") if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO diff --git a/scripts/live/scale_watch.py b/scripts/live/scale_watch.py new file mode 100644 index 0000000..4950a43 --- /dev/null +++ b/scripts/live/scale_watch.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Sorveglianza giornaliera della CHIAVE DI SCALA del libro (SPEC-scale-key §6.4). + + uv run python scripts/live/scale_watch.py # osserva e stampa, invia se ALLARME + uv run python scripts/live/scale_watch.py --secco # osserva e stampa, NON invia nulla + uv run python scripts/live/scale_watch.py --quiet # una riga sola (per il cron) + +Tre domande, tre azioni diverse, tre stati (OK / ALLARME / NON MISURABILE). +Non impedisce la modifica: la rende visibile entro 24 ore e attribuibile. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.live.notifier import notify # noqa: E402 +from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402 + + +def _sender(testo: str): + try: + notify(testo) + return True, "telegram" + except Exception as e: # P3: si registra dove lo si ingoia + return False, f"{type(e).__name__}: {e}" + + +def main() -> None: + secco = "--secco" in sys.argv + quiet = "--quiet" in sys.argv + rep = run_once(sender=None if secco else _sender) + icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️") + if quiet: + fb = rep["fallback"] + q = f"{100*fb['quota']:.2f}%" if "quota" in fb else "n/d" + print(f" scale_watch: {icona} {rep['stato']} · scala {rep['scala_dichiarata']:.2f}x · " + f"fallback {q} · {rep['invio']}") + return + print("=" * 88) + print(f" SCALE WATCH — la chiave di scala del libro {icona} {rep['stato']}") + print("=" * 88) + print(f" scala dichiarata : {rep['scala_dichiarata']:.2f}x " + f"(scaletta {rep['ladder']}, tetto {rep['tetto']:.2f}x — costanti di CODICE)") + for nome, d in rep["domande"].items(): + print(f"\n {nome} [{d['stato']}] {d['motivo']}") + if d.get("azione") and d["azione"] != "nessuna": + print(f" → azione: {d['azione']}") + fb = rep["fallback"] + if fb.get("stato") == "NON MISURABILE": + print(f"\n fallback [{fb['stato']}] {fb.get('motivo')}") + else: + print(f"\n fallback [{fb['stato']}] equity illeggibile in {fb['fallback_veri']}/{fb['giri']} " + f"giri ({100*fb['quota']:.2f}%, soglia {100*fb['soglia']:.0f}%) — " + f"{fb['paper']} giri pre-finanziamento esclusi, li' il libro non invia affatto") + print(f"\n lettore di produzione [{rep['lettore']['stato']}] {rep['lettore']['motivo'][:150]}") + print(f"\n invio: {rep['invio']}") + + +if __name__ == "__main__": + main() diff --git a/src/live/book.py b/src/live/book.py index b139b3a..a0a02a5 100644 --- a/src/live/book.py +++ b/src/live/book.py @@ -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. diff --git a/src/live/scale_watch.py b/src/live/scale_watch.py new file mode 100644 index 0000000..ae96caa --- /dev/null +++ b/src/live/scale_watch.py @@ -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 diff --git a/tests/test_book_scale.py b/tests/test_book_scale.py new file mode 100644 index 0000000..e5714e0 --- /dev/null +++ b/tests/test_book_scale.py @@ -0,0 +1,305 @@ +"""T1-T11 — le guardie della CHIAVE DI SCALA (SPEC-scale-key §5). + +Scritti come specifiche eseguibili: ogni test dichiara **il caso che deve farlo fallire**. +La chiave e' inerte finche' nessuno scrive un numero in config: T7 lo dimostra bit-exact. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from src.live import book as B +from src.live import scale_watch as SW + +ROOT = Path(__file__).resolve().parents[1] + +GRIGLIA_K = (1.00, 1.10, 1.25, 1.50, 2.00) +GRIGLIA_TP = (0.0, 0.25, 0.461, 1.0) +GRIGLIA_SG = (-1, 0, 1) +CAP_SPENTO = 1e12 # cap disattivato: isola la scala dal clamp +EQ = 2000.0 + + +def _cfg(**kw) -> dict: + base = dict(max_notional_per_asset_frac=0.5, disaster_sl_pct=0.3) + base.update(kw) + return base + + +# ---------------------------------------------------------------- T1 / T1b: la scala e' SIMMETRICA + +def test_T1_la_scala_e_identica_sui_due_sleeve(): + """target(k) == k*target(1) su tutta la griglia, e il rapporto TP/SKH resta W_TP01/W_SKH. + DEVE FALLIRE SE: qualcuno applica la scala a una sola gamba (o dentro TrendPortfolio).""" + for k in GRIGLIA_K: + for tp in GRIGLIA_TP: + for sg in GRIGLIA_SG: + uno = B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=1.0) + kk = B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=k) + assert kk == pytest.approx(k * uno, abs=1e-9), (k, tp, sg) + solo_tp = B.book_net_target(1.0, 0, EQ, CAP_SPENTO, scala=k) + solo_skh = B.book_net_target(0.0, 1, EQ, CAP_SPENTO, scala=k) + assert solo_tp / solo_skh == pytest.approx(B.W_TP01 / B.W_SKH, abs=1e-12), k + + +def test_T1b_il_test_di_simmetria_smaschera_una_scala_asimmetrica(): + """CONTROLLO POSITIVO — il test piu' importante dell'elenco. + + A k=1 l'implementazione simmetrica e quella asimmetrica danno lo STESSO numero: un test di + simmetria scritto sul solo caso di default passa sempre e non controlla niente (potenza ZERO). + Qui si verifica (a) che a k=1 le due coincidano davvero, e (b) che fuori da k=1 l'asserzione + di T1 le SEPARI. + """ + def asimmetrico(tp, sg, eq, cap, scala=1.0): + """La scala applicata al solo TP01: e' anche un TILT di pesi, non una scala.""" + raw = B.WEIGHT * eq * (B.W_TP01 * scala * max(tp, 0.0) + B.W_SKH * float(sg)) + return max(-cap, min(cap, raw)) + + # (a) a k=1 le due implementazioni sono indistinguibili -> il test avrebbe potenza zero + for tp in GRIGLIA_TP: + for sg in GRIGLIA_SG: + assert asimmetrico(tp, sg, EQ, CAP_SPENTO, 1.0) == pytest.approx( + B.book_net_target(tp, sg, EQ, CAP_SPENTO, scala=1.0), abs=1e-12) + + # (b) fuori da k=1 l'asserzione di T1 fallisce sull'implementazione asimmetrica + fallita = False + for k in (1.25, 2.00): + uno = asimmetrico(1.0, 1, EQ, CAP_SPENTO, 1.0) + kk = asimmetrico(1.0, 1, EQ, CAP_SPENTO, k) + if abs(kk - k * uno) > 1e-9: + fallita = True + peso_tp = (asimmetrico(1.0, 0, EQ, CAP_SPENTO, k) + / (asimmetrico(1.0, 0, EQ, CAP_SPENTO, k) + asimmetrico(0.0, 1, EQ, CAP_SPENTO, k))) + assert peso_tp != pytest.approx(B.W_TP01, abs=1e-6), ( + f"a k={k} il peso implicito di TP01 dovrebbe DERIVARE dallo 0,75: {peso_tp}") + assert fallita, "T1 non separa la scala asimmetrica: il controllo positivo e' rotto" + + +# ---------------------------------------------------------------- T2 / T3: i due tetti + +def test_T2_la_leva_lorda_da_config_resta_sotto_il_tetto(): + """len(ASSETS) * frac * scala <= LEVA_LORDA_MAX, con frac e scala LETTI da config/live.json e + il tetto IMPORTATO dalla produzione. + DEVE FALLIRE SE: qualcuno alza frac, o la scala, **o entrambe di poco**.""" + cfg = json.loads((ROOT / "config" / "live.json").read_text()) + frac = cfg.get("max_notional_per_asset_frac") + if frac is None: + pytest.skip("cap fisso: la leva non e' derivabile da frac") + scala = float(cfg.get("book_scale_k", 1.0)) + leva = len(B.ASSETS) * float(frac) * scala + assert leva <= B.LEVA_LORDA_MAX + 1e-12, ( + f"leva lorda {leva:.4f}x oltre il tetto {B.LEVA_LORDA_MAX}x — il tetto e' sul PRODOTTO: " + f"n_asset {len(B.ASSETS)} x frac {frac} x scala {scala}") + + +def test_T3_l_invariante_disaster_sl_tiene_meta_equity(): + """n_asset * frac * scala * disaster_sl_pct <= 0.50 — la guardia che morde per PRIMA. + DEVE FALLIRE SE: qualcuno allarga disaster_sl_pct **o** alza la scala fino a 1,67x.""" + cfg = json.loads((ROOT / "config" / "live.json").read_text()) + frac = cfg.get("max_notional_per_asset_frac") + if frac is None: + pytest.skip("cap fisso") + costo = (len(B.ASSETS) * float(frac) * float(cfg.get("book_scale_k", 1.0)) + * float(cfg["disaster_sl_pct"])) + assert costo <= B.DISASTER_SL_BUDGET + 1e-12, ( + f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity") + + +# ---------------------------------------------------------------- T4: il fallback non e' permissivo + +def test_T4_il_fallback_non_e_piu_permissivo_del_percorso_fidato(tmp_path, monkeypatch): + """In OGNI stato degradato il lordo massimo / equity vera <= quello dello stato normale. + DEVE FALLIRE SE: qualcuno applica la scala anche in fallback, o slega il cap dal watermark.""" + cfg = _cfg(book_scale_k=1.25, max_notional_per_asset_usd=3000) + cpath = tmp_path / "live.json" + cpath.write_text(json.dumps(cfg)) + monkeypatch.setattr(B, "CONFIG", cpath) + wm = tmp_path / "equity_seen.json" + wm.write_text(json.dumps({"real_equity": EQ})) + monkeypatch.setattr(B, "EQUITY_WATERMARK", wm) + + def lordo(real_equity, eq_fallback): + cap = B._cap(equity=EQ, real_equity=real_equity, eq_fallback=eq_fallback) + sc = B._scala(real_equity=real_equity, eq_fallback=eq_fallback) + return len(B.ASSETS) * cap * sc / EQ + + normale = lordo(EQ, None) + assert normale == pytest.approx(1.25, abs=1e-9) + for nome, re_, fb in (("equity illeggibile", None, None), + ("eq_fallback attivo", EQ, "paper")): + assert lordo(re_, fb) <= normale + 1e-12, nome + + wm.unlink() # watermark assente: due protezioni indipendenti + senza_wm = lordo(None, None) + assert senza_wm <= normale + 1e-12 + assert B._scala(real_equity=None, eq_fallback=None) == 1.0 + + +# ---------------------------------------------------------------- T5: il cap limita ancora + +def test_T5_il_cap_continua_a_limitare_cio_che_limitava(): + """Il target non supera mai cap*scala, e il clamp MORDE ancora. + DEVE FALLIRE SE: qualcuno inverte l'ordine, o toglie il clamp 'tanto c'e' la scala'.""" + cap = 500.0 + morso = 0 + for k in GRIGLIA_K: + for tp in GRIGLIA_TP: + for sg in GRIGLIA_SG: + net = B.book_net_target(tp, sg, EQ, cap, scala=k) + assert abs(net) <= cap * k + 1e-9, (k, tp, sg, net) + grezzo = B.WEIGHT * EQ * (B.W_TP01 * max(tp, 0.0) + B.W_SKH * sg) + if abs(grezzo) > cap: + morso += 1 + assert abs(net) == pytest.approx(cap * k, abs=1e-9) + assert morso > 0, "il clamp non morde su nessuna osservazione: il test non controlla nulla" + + +# ---------------------------------------------------------------- T6: fuori tetto = STOP, non clamp + +@pytest.mark.parametrize("valore,perche", [ + (1.05, "fuori SCALA_LADDER: 'solo un po'' dev'essere inesprimibile"), + (1.50, "fuori SCALA_LADDER e oltre il tetto"), + (2.00, "oltre il tetto"), +]) +def test_T6_una_scala_fuori_tetto_ferma_e_non_viene_tagliata(valore, perche): + """DEVE FALLIRE SE: qualcuno 'rende robusto' il lettore con un min(scala, TETTO/...).""" + with pytest.raises(B.ScalaNonAutorizzata): + B._scala(real_equity=EQ, eq_fallback=None, cfg=_cfg(book_scale_k=valore)) + + +def test_T6b_il_tetto_e_sul_PRODOTTO_non_sulla_sola_chiave(): + """frac 0,625 x scala 1,25 = 1,562x: passa un tetto sulla sola chiave, DEVE essere bloccato.""" + with pytest.raises(B.ScalaNonAutorizzata): + B._scala(real_equity=EQ, eq_fallback=None, + cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625)) + # e la validazione avviene ANCHE sul percorso degradato: una config rotta non si nasconde + with pytest.raises(B.ScalaNonAutorizzata): + B._scala(real_equity=None, eq_fallback="paper", + cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625)) + + +def test_T6c_l_invariante_disaster_sl_blocca_chi_allarga_lo_stop(): + """Con sl 0,45 e scala 1,25 il costo di un disaster-SL supera mezza equity: STOP.""" + with pytest.raises(B.ScalaNonAutorizzata): + B._scala(real_equity=EQ, eq_fallback=None, + cfg=_cfg(book_scale_k=1.25, disaster_sl_pct=0.45)) + + +# ---------------------------------------------------------------- T7: inerzia bit-exact + +def test_T7_senza_la_chiave_il_libro_e_quello_di_oggi(): + """Con chiave assente o = 1.0 il target e' BIT-EXACT alla formula pre-modifica. + DEVE FALLIRE SE: la modifica cambia qualcosa PRIMA che qualcuno decida di cambiarlo.""" + def prima(tp, sg, eq, cap, weight=B.WEIGHT): + raw = weight * eq * (B.W_TP01 * max(tp, 0.0) + B.W_SKH * float(sg)) + return max(-cap, min(cap, raw)) + + peggiore = 0.0 + for cap in (50.0, 500.0, CAP_SPENTO): + for tp in GRIGLIA_TP: + for sg in GRIGLIA_SG: + a = prima(tp, sg, EQ, cap) + for scala in (1.0,): + peggiore = max(peggiore, abs(B.book_net_target(tp, sg, EQ, cap, scala=scala) - a)) + assert peggiore == 0.0, f"la modifica non e' inerte: max|diff| = {peggiore}" + assert B._scala(real_equity=EQ, eq_fallback=None, cfg=_cfg()) == 1.0 + assert float(json.loads((ROOT / "config" / "live.json").read_text()) + .get("book_scale_k", 1.0)) == 1.0, "la chiave in config non e' 1,00" + + +# ---------------------------------------------------------------- T8: il report la dichiara + +def test_T8_il_report_e_il_log_dichiarano_la_scala(): + """DEVE FALLIRE SE: un parametro che muove il nozionale resta invisibile nel report.""" + src = (ROOT / "src" / "live" / "book.py").read_text() + assert "scala=scala," in src, "book_report non espone `scala` nel dict di ritorno" + ex = (ROOT / "scripts" / "live" / "book_execute.py").read_text() + assert "scala libro" in ex, "book_execute non stampa la scala" + assert "ScalaNonAutorizzata" in ex, "book_execute non gestisce la scala non autorizzata" + + +# ---------------------------------------------------------------- T9: derivata, non ridichiarata + +def test_T9_la_scala_e_derivata_dal_codice_non_ridichiarata(): + """Il sorvegliante IMPORTA i bersagli dalla produzione. DEVE FALLIRE SE: due liste in due file.""" + assert SW.LEVA_LORDA_MAX is B.LEVA_LORDA_MAX + assert SW.SCALA_LADDER is B.SCALA_LADDER + assert SW.ASSETS is B.ASSETS + assert SW.DISASTER_SL_BUDGET is B.DISASTER_SL_BUDGET + testo = (ROOT / "src" / "live" / "scale_watch.py").read_text() + for vietato in ("LEVA_LORDA_MAX =", "SCALA_LADDER =", "DISASTER_SL_BUDGET ="): + assert vietato not in testo, f"scale_watch RIDICHIARA {vietato} invece di importarlo" + + +# ---------------------------------------------------------------- T10: il sorvegliante segnala + +def test_T10_il_sorvegliante_segnala_una_scala_non_autorizzata(tmp_path): + """CONTROLLO POSITIVO del §6.4. DEVE FALLIRE SE: il sorvegliante e' tarato per non segnalare mai + (un rilevatore che non segnala mai e' indistinguibile da uno rotto).""" + giornale = [{"scala": 1.00, "dal": "2026-09-01T00:00:00+00:00", "gate": "iniziale"}] + + muto = SW.osserva(cfg=_cfg(book_scale_k=1.00), giornale=giornale) + assert muto["stato"] == SW.OK, muto["domande"] + + parlante = SW.osserva(cfg=_cfg(book_scale_k=1.25), giornale=giornale) + assert parlante["stato"] == SW.ALLARME + assert parlante["domande"]["D1"]["stato"] == SW.ALLARME, "D1 non vede la scala non autorizzata" + + vuoto = SW.osserva(cfg=_cfg(book_scale_k=1.25), giornale=[]) + assert vuoto["domande"]["D1"]["stato"] == SW.ALLARME, "giornale vuoto non e' un'autorizzazione" + + sfora = SW.osserva(cfg=_cfg(book_scale_k=1.25, max_notional_per_asset_frac=0.625), + giornale=giornale) + assert sfora["domande"]["D3"]["stato"] == SW.ALLARME, "D3 non vede il prodotto oltre il tetto" + + +def test_T10b_una_allerta_per_streak_non_una_per_giro(tmp_path): + """Disciplina degli allarmi: si dice UNA VOLTA per streak. E il marcatore 'gia' detto' si + scrive SOLO dopo un invio RIUSCITO (debito #2): con trasporto rotto si ridice.""" + st = tmp_path / "state.json" + cfg = _cfg(book_scale_k=1.25) + giornale = [{"scala": 1.00, "dal": "2026-09-01T00:00:00+00:00"}] + inviati = [] + + def ok_sender(t): + inviati.append(t) + return True, "finto" + + a = SW.run_once(state_path=st, sender=ok_sender, cfg=cfg, giornale=giornale) + b = SW.run_once(state_path=st, sender=ok_sender, cfg=cfg, giornale=giornale) + assert "D1" in a["da_dire"] and "D1" not in b["da_dire"], "ridice a ogni giro" + assert len(inviati) == 1 + + st.unlink() + rotti = [] + + def ko_sender(t): + rotti.append(t) + return False, "trasporto giu'" + + SW.run_once(state_path=st, sender=ko_sender, cfg=cfg, giornale=giornale) + c = SW.run_once(state_path=st, sender=ko_sender, cfg=cfg, giornale=giornale) + assert "D1" in c["da_dire"], "un invio FALLITO ha marcato 'gia' detto': l'allarme e' perso" + assert len(rotti) == 2 + + +def test_T10c_tre_stati_non_due(tmp_path): + """'Non vedo' non e' 'va tutto bene': deve esistere NON MISURABILE.""" + r = SW.osserva(cfg=dict(disaster_sl_pct=0.3), giornale=[]) # frac assente + assert r["domande"]["D3"]["stato"] == SW.NON_MISURABILE + fb = SW.frequenza_fallback(log=tmp_path / "non-esiste.log") + assert fb["stato"] == SW.NON_MISURABILE + + +# ---------------------------------------------------------------- T11: sostituito, non rilassato + +def test_T11_il_vecchio_test_di_leva_e_stato_sostituito_non_rilassato(): + """DEVE FALLIRE SE: qualcuno alza la soglia del vecchio test da 1,0 a 1,25 e chiude li'.""" + fs = (ROOT / "tests" / "test_fee_sensitivity.py").read_text() + assert "def test_leva_massima_da_config_resta_sotto_o_uguale_a_1x" not in fs, ( + "il vecchio test misurava frac*n_asset mentre la grandezza vera e' frac*n_asset*scala: " + "va SOSTITUITO da T2/T3, non rilassato") + qui = Path(__file__).read_text() + assert "def test_T2_" in qui and "def test_T3_" in qui