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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user