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
+28 -8
View File
@@ -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<data>_*.py). Harness condiviso: alt/altlib.py
+166
View File
@@ -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.
+12 -2
View File
@@ -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).
+6
View File
@@ -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.
+24 -3
View File
@@ -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
+62
View File
@@ -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()
+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
+305
View File
@@ -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