cinque punti della revisione 09/09: usde_watch a 1000 trade + finestra 24h, cuscino_watch (equity USDC, riconverte da solo), PREVDAY-01 kill/veto cablati, SCALA-01 al 2027-02-28, versamento 04/09 dichiarato

Decisi dall'operatore il 2026-09-10, verificati da revisione fable (15 segnalazioni, 12 applicate).

- usde_watch: TRADE_LIMIT 1000 (count max Deribit) e trade_copertura(): lista troncata o ultima
  lettura oltre la finestra di 24h del gateway => somma NON leggibile, reward non attribuito (P12).
  Riga del 07/09 corretta nel log con campo `correzione` (400 USDE erano acquisti, non reward).
- cuscino_watch.py (cron :53, monitor_health): equity USDC contro cuscino derivato da
  usde.cuscino_richiesto_usd (formula spostata in src/live/usde.py, usde_convert la importa);
  OK/PREAVVISO/SCOPERTO/BLIND; sotto zero lancia usde_convert --quota quota_ripristino(0.20)=0.64
  --esegui con guardie (execution_enabled, depeg_warn, 1 tentativo/6h). Primo giro: PREAVVISO, +$26.
- usde_convert: il tetto del venue vale solo in ACQUISTO (bloccava la vendita).
- paper_prevday: GATE PREVDAY-01 cablato (2027-06-21, kill Sharpe giornaliero < -0,50 su >=180 g
  attivi, veto >=80% barre ricostruibili + divergenze non crescenti con soglia materiale).
  Oggi: +0,95 su 81 g, 1942/1943 ricostruibili, kill NON MATURO.
- CLAUDE.md: arming 20/06 (TP01) / 23/06 (BOOK); piano EUR 5.000 chiuso col versamento 04/09
  ($2.414,68 dal balance, dichiarato); SCALA-01 non prima del 2027-02-28 (A2 dal 01/09);
  PREVDAY-01 con data, kill, veto; §5.17 riparato con i limiti dichiarati (ratchet, slack zero a 0,70).
- test: 1062 (+31): test_cuscino_watch (16), test_paper_prevday_gate (8), test_usde_watch (+5).

Fixes #2
Fixes #3
Fixes #4
Fixes #5
Fixes #6

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
This commit is contained in:
Adriano Dal Pastro
2026-09-10 13:24:28 +00:00
parent 79afe41ec2
commit f82f685528
13 changed files with 1110 additions and 36 deletions
+23 -14
View File
@@ -38,7 +38,7 @@ Documento di fondazione: `docs/diary/2026-06-19-deribit-history.md`.
gia' dichiarati artefatti. Riscritto contro lo stato vero. **La lezione non e' "aggiornare il gia' dichiarati artefatti. Riscritto contro lo stato vero. **La lezione non e' "aggiornare il
README": e' che un reset invalida anche i documenti che nessuno rilegge** — l'inventario di cosa README": e' che un reset invalida anche i documenti che nessuno rilegge** — l'inventario di cosa
cita numeri morti va fatto il giorno del reset, non quando qualcuno ci inciampa. cita numeri morti va fatto il giorno del reset, non quando qualcuno ci inciampa.
- **L'esecuzione e' ARMATA e LIVE** su Deribit mainnet dal 2026-06-20. Capitale reale **~$4.495** dal 04/09 (era ~$635 fino al 25/08, ~$2.060 fino al 04/09) - **L'esecuzione e' ARMATA e LIVE** su Deribit mainnet: **TP01 da solo dal 2026-06-20** (commit `4650aa7`), **il BOOK TP01+SKH01 dal 2026-06-23** (commit `db738bc`, prima lettura di equity 23/06 22:00Z — e' la data d'arming che il TWR e `config/live.json` usano). Capitale reale **~$4.495** dal 04/09 (era ~$635 fino al 25/08, ~$2.060 fino al 04/09)
(NON i €2.000 nominali del paper trader). (NON i €2.000 nominali del paper trader).
- Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia della §8. - Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia della §8.
@@ -108,8 +108,11 @@ le quattro gia' chiuse il 31/08. L'USDC sul conto **non e' fermo** (base di sizi
vero sta fuori (~€6k in XEON = lo split-cassa): le opzioni sicure valgono **+€52-62/anno** (BOT 12,5% / vero sta fuori (~€6k in XEON = lo split-cassa): le opzioni sicure valgono **+€52-62/anno** (BOT 12,5% /
conto deposito 26%), sUSDe rende 2x ma e' **lo stesso emittente dell'USDE** e distrugge lo split. conto deposito 26%), sUSDe rende 2x ma e' **lo stesso emittente dell'USDE** e distrugge lo split.
**L'operatore ha scelto di VERSARE** (piano 27/07: **€5.000 dentro, ~€1.000 fuori** — la protezione **L'operatore ha scelto di VERSARE** (piano 27/07: **€5.000 dentro, ~€1.000 fuori** — la protezione
venue satura a qualunque quota > 0). Nessuna azione di config. **In attesa di importo e data**; al venue satura a qualunque quota > 0). **VERSATO il 04/09: $2.414,68 di USDC** (dal balance, fra 13:42Z e
deposito va **una riga di giornale**, non ricostruita dopo. `r0901d_soldi_fermi.py`. 14:42Z), **dichiarato il 10/09** in `movimenti_dichiarati.jsonl` — e **l'operatore ha detto che e' l'INTERO
versamento previsto: il piano €5.000 e' chiuso qui**, non resta nulla «in attesa». Per sei giorni era stato solo
*rilevato* (+$2.410,14 dal salto di equity, col mercato dell'ora dentro): la regola di §2 dice il giorno stesso.
`r0901d_soldi_fermi.py`.
**Candidati in forward-monitor** (nessuno nel book): XSR01 · DVOLSPREAD · STATARB · PREVDAY. **Candidati in forward-monitor** (nessuno nel book): XSR01 · DVOLSPREAD · STATARB · PREVDAY.
@@ -143,7 +146,7 @@ sono all'ancora fortunata; le stime oneste sono la **mediana della banda d'ancor
| P(≥50 €/g), canale funded, 36 mesi | 42% | **2,6%** [1,5 4,7], P(zero) 40,3% | | P(≥50 €/g), canale funded, 36 mesi | 42% | **2,6%** [1,5 4,7], P(zero) 40,3% |
| XSR01 | Sharpe 1,82 (e' una **terza** lente, divisore fisso 50) | **1,79** alla scoperta / **1,56-1,63** a oggi — lente dei gate, sole barre chiuse. Citare sempre la coppia (lente, ultima barra chiusa) | | XSR01 | Sharpe 1,82 (e' una **terza** lente, divisore fisso 50) | **1,79** alla scoperta / **1,56-1,63** a oggi — lente dei gate, sole barre chiuse. Citare sempre la coppia (lente, ultima barra chiusa) |
| soffitto direzionale BTC/ETH | ~1,3 | **~1,15** col funding dentro | | soffitto direzionale BTC/ETH | ~1,3 | **~1,15** col funding dentro |
| performance del libro LIVE | «+243%» di equity (e' **96,3% un bonifico**) · e **+11,98%** (lettura 06/09 20:47Z, PRIMA della dichiarazione del versamento di prova) | **TWR +7,83%** spezzato sui TRE movimenti — +8,14% fino al 25/08 08:00, 0,62% / 0,12% / +0,46% dopo (lettura 06/09 21:14Z); trading da arming **+$62,45**. Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso). 🚨 **Il 06/09 l'operatore ha dichiarato un versamento di PROVA di €25 il 25/08 alle 08:00Z**, ~$24,9: +3,3% su $647, **sotto la soglia del 10%** del rilevatore, quindi per 12 giorni e' stato contato come trading (un terzo del «trading da arming» e 4 punti di TWR). Ora sta in `data/live/movimenti_dichiarati.jsonl`, letto da `journal.movimenti_capitale` (fonte `dichiarato`, importo dell'operatore, il mercato dell'ora resta nel rendimento). **Regola: ogni versamento, anche di prova, va dichiarato li' il giorno stesso** — il rilevatore vede solo i salti ≥10% | | performance del libro LIVE | «+243%» di equity (e' **96,3% un bonifico**) · e **+11,98%** (lettura 06/09 20:47Z, PRIMA della dichiarazione del versamento di prova) | **TWR +6,66%** spezzato sui TRE movimenti in QUATTRO tratti — +8,14% fino al 25/08 08:00, 0,62% / 0,12% / **0,63%** dopo (lettura 10/09 13:04Z, dopo la dichiarazione del versamento del 04/09 a **$2.414,68** dal balance); trading da arming **+$13,82**. Era +7,83% / +$62,45 il 06/09: la coda e' la marcatura delle due long dal picco del 06/09, non trade chiusi. Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso). 🚨 **Il 06/09 l'operatore ha dichiarato un versamento di PROVA di €25 il 25/08 alle 08:00Z**, ~$24,9: +3,3% su $647, **sotto la soglia del 10%** del rilevatore, quindi per 12 giorni e' stato contato come trading (un terzo del «trading da arming» e 4 punti di TWR). Ora sta in `data/live/movimenti_dichiarati.jsonl`, letto da `journal.movimenti_capitale` (fonte `dichiarato`, importo dell'operatore, il mercato dell'ora resta nel rendimento). **Regola: ogni versamento, anche di prova, va dichiarato li' il giorno stesso** — il rilevatore vede solo i salti ≥10% |
📌 **Il libro a k=1 rende MENO dell'S&P 500** (15,19% contro 17,40%, stessa finestra): il vantaggio 📌 **Il libro a k=1 rende MENO dell'S&P 500** (15,19% contro 17,40%, stessa finestra): il vantaggio
sta nello **Sharpe** (1,35 vs 0,89), e **senza leva non si converte in rendimento**. A iso-rischio sta nello **Sharpe** (1,35 vs 0,89), e **senza leva non si converte in rendimento**. A iso-rischio
@@ -255,8 +258,8 @@ regolamento (§1). Quanto segue e' la misura del 30-31/08, vera allora, tenuta p
| **XSR01** | 2026-10-23 | Sharpe ≥1,0 **E** haircut ≤40% — dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti — poi `weights_tilt_null` e **capitale ≥$20k** | ✅ **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe 2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (📌 sotto) | | **XSR01** | 2026-10-23 | Sharpe ≥1,0 **E** haircut ≤40% — dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti — poi `weights_tilt_null` e **capitale ≥$20k** | ✅ **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe 2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (📌 sotto) |
| **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 | | **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 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 PREVDAY-01** | decisione **2027-06-21** (scritto 23/08, `RESULTS-0822` §54); kill leggibile da ~18/12/2026 | 7 condizioni (a)-(g) tutte necessarie + **veto d'integrita'** (≥80% barre ricostruibili, divergenze non crescenti) + **kill** Sharpe forward **giornaliero** < 0,50 su ≥180 giorni attivi, senza ri-ottimizzare | **5/7** (= l'«8/10» di §54 e di `20-ondate`, contato su una base a 10 voci: stessa sostanza, base diversa): la cella che GIRA non e' quella che la selezione onesta sceglie, e quella viva **fallisce il DSR** (0,905) — (a) e (b) sono STRUTTURALI, nessun giorno di forward le cambia. ✅ **Kill e veto cablati il 10/09** in `paper_prevday.py` (issue #4; fino ad allora esistevano solo nel testo e la revisione del 09/09 lesse «senza data»): al 10/09 Sharpe giornaliero +0,95 su 81 giorni (MDE ±4,25), 1942/1943 barre ricostruibili, kill NON MATURO. Decisione dell'operatore (10/09): si tiene la data — verso i 50 €/g PREVDAY vale centesimi, non si spende altro |
| **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' | | **GATE SCALA-01** (portare `book_scale_k` da 1,00 a 1,25) | non prima del **2027-02-28** (= 180 giorni dal **01/09**, primo giorno di `scale_watch`: A2 conta da quando il criterio e' MISURATO, non dall'arming — decisione dell'operatore 10/09, issue #5. «Non prima del 2026-10-01», scritto il 01/09, era incompatibile con A2 per costruzione) | 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** | | **`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 📌 **XSR01 sotto la lente RENDITA (25/08): il meccanismo vale ~0.** Mescolarne i rendimenti da' lo
@@ -438,13 +441,19 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
`valida` sia la prima istruzione, che l'uso documenti i flag accettati, e che **i flag che il `valida` sia la prima istruzione, che l'uso documenti i flag accettati, e che **i flag che il
cron usa davvero restino accettati** (P15/P16). Diario `2026-09-02c-flag-e-pulizia.md`. cron usa davvero restino accettati** (P15/P16). Diario `2026-09-02c-flag-e-pulizia.md`.
17. ⚠️ **Nessuno sorveglia il CUSCINO USDC di regolamento** (aperto il 2026-09-06, quando la quota USDE e' 17. **RIPARATO (2026-09-10, issue #3).** Nessuno sorvegliava il CUSCINO USDC di regolamento (aperto il 06/09
salita al 68,7%). P&L e funding dei perp si regolano in USDC: una perdita del libro consuma il cuscino e con la quota USDE al 68,7%): P&L e funding dei perp si regolano in USDC, una perdita del libro lo consuma e fa
fa salire la quota USDE da sola, senza alcun ordine. `usde_watch` allerta solo su `quota > quota_max_frac` salire la quota da sola; `usde_watch` allerta solo su `quota > 0,85`, e un USDC negativo costa lo 0,05%/giorno.
(0,85): a 68,7% lo slack sul cuscino e' **$58**, e un saldo USDC negativo costa lo 0,05%/giorno. Il Ora **`scripts/live/cuscino_watch.py`** (cron **:53**, orario, `cron_cuscino.sh`, sorvegliato da `monitor_health`)
sorvegliante giusto legge `USDC cuscino_richiesto` (derivato da config come in legge l'**EQUITY USDC** (decisione dell'operatore: e' quella che risponde del regolamento, il balance vede il buco
`usde_convert.cuscino_richiesto_usd`, P1) ogni ora e allerta sotto zero; e la riconversione USDE→USDC che solo a trade chiuso), deriva il cuscino da `usde.cuscino_richiesto_usd` — **la formula si e' spostata in
ripristina il cuscino e' `usde_convert --quota <minore>`, che esiste ma non e' automatica. `src/live/usde.py`, e `usde_convert` la importa da li'** (due lettori, una formula: P1) — e ha tre stati:
OK · **PREAVVISO** (slack < 10% del cuscino, ⚠️ alla transizione) · **SCOPERTO** (slack < 0: 🚨 e
**riconversione automatica** `usde_convert --quota usde.quota_ripristino(0,20) --esegui`, cioe' **0,64**, che lascia
il cuscino piu' un 20% — il DOPPIO del preavviso, altrimenti ogni 🚨 sarebbe seguito da un ⚠️ per costruzione). Guardie: `execution_enabled` del libro (disarmare il libro disarma anche questo), niente
vendita sotto `depeg_warn` (decide l'operatore), un tentativo ogni 6h, le guardie proprie di `usde_convert`;
marcatore «gia' detto» = esito dell'invio (§5.2). **Primo giro (13:00Z): gia' PREAVVISO, slack +$26** su $1.335
richiesti — la marcatura del libro dal 06/09 lo aveva consumato senza che nessuno lo vedesse. ⚠️ **Due limiti dichiarati** (revisione fable 10/09): (a) **a quota 0,70 lo slack e' ZERO per costruzione**, quindi PREAVVISO e' lo stato normale del conto a quota piena (allerta una volta); (b) **il ripristino e' un RATCHET verso il basso** — dopo una riconversione la quota resta a 0,64 e nessun automatismo la riporta a 0,70: ricomprare e' `usde_convert --quota 0.70` a mano, decisione dell'operatore (N9: va deciso se accettare il ratchet o autorizzare un riacquisto automatico quando lo slack torna sopra il margine). Il tetto del venue in `usde_convert.piano` ora vale **solo in acquisto**: bloccava anche la vendita, e con `venue_cap_frac` rimesso in config la riconversione sarebbe stata morta. `tests/test_cuscino_watch.py` (16).
18.**RIPARATO (2026-09-09 sera).** `cblib.spot_series` + `asof(ts)` guardava **un'ora avanti** (feed 18.**RIPARATO (2026-09-09 sera).** `cblib.spot_series` + `asof(ts)` guardava **un'ora avanti** (feed
1h etichettato all'apertura: verificato, chiusura 1h a T == chiusura 5m a T+55 nel 100% delle barre) 1h etichettato all'apertura: verificato, chiusura 1h a T == chiusura 5m a T+55 nel 100% delle barre)
@@ -689,7 +698,7 @@ uv run python scripts/live/trades_db.py --reconcile # incrocio dell
uv run python scripts/live/journal.py # voce del giorno (numeri + lettura) uv run python scripts/live/journal.py # voce del giorno (numeri + lettura)
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run python scripts/live/revisione.py --secco # taglia del materiale della revisione settimanale (niente modello) uv run python scripts/live/revisione.py --secco # taglia del materiale della revisione settimanale (niente modello)
uv run pytest # test (1031, tutti verdi al 09/09 sera) uv run pytest # test (1062, tutti verdi al 10/09)
``` ```
```python ```python
@@ -0,0 +1,156 @@
# I cinque punti della revisione del 09/09 — decisi e fatti
**Data:** 2026-09-10 (sessione 12:08Z → 13:10Z; i timestamp qui dentro sono letti, non ricordati)
**Origine:** la prima revisione settimanale (`docs/revisioni/2026-09-09.md`, revisore `fable`) e il
giornale del 09/09. Ogni punto e' stato spiegato all'operatore con pro e contro e **deciso da lui**
(§3-style: decisioni con l'informazione completa). Issue sul Gitea di casa #2-#6, chiuse con la
soluzione scritta (regola globale del 10/09).
## Le decisioni, in una tabella
| # | punto | decisione dell'operatore | issue |
|---|---|---|---|
| 1 | `usde_watch` contava 400 USDE come reward (07/09) | ripara ora + issue | #2 bug |
| 2 | cuscino USDC senza sorvegliante (§5.17) | **equity USDC, riconverte da solo** | #3 sviluppo |
| 3 | versamento 04/09 solo rilevato; piano €5.000 «in attesa» | **e' l'INTERO versamento previsto**: dichiarato, piano chiuso | #6 sviluppo |
| 4 | SCALA-01 A2: da quando contano i 180 giorni | **dal 01/09** (sorvegliante attivo) → gate non prima del **2027-02-28** | #5 bug |
| 5 | PREVDAY-01 «senza data» | **tieni 21/06/2027, cabla kill e veto, correggi §4** | #4 bug |
Sul punto 5 la domanda giusta e' stata «ricorda dove dobbiamo arrivare (50 €/g)»: PREVDAY
de-luckato al 15% vale +0,10 di Sharpe sul libro e la somma di *tutti* i lead dell'ondata di agosto
vale +0,036 €/giorno. Verso i 50 €/g il candidato vale centesimi in qualunque esito del gate:
non si spende una sessione di ricerca per anticipare (a)+(b), e non si butta l'unica gamba short
scorrelata oltre a SKH01 per risparmiare un cron. Costo scelto: mezza giornata, una volta.
## Punto 1 — il lettore dei trade spot aveva DUE limiti, non uno
Il primo era noto dalla revisione: `trade_history(limit=50)` con 54 ordini. Il secondo l'ho
trovato sondando il gateway per ripararlo: `get_user_trades_by_instrument` chiamato senza
`historical` restituisce **solo le ultime ~24 ore** — il fill BTC dell'08/09 06:47Z e' invisibile
alle 12:55Z del 10/09, quello del 09/09 21:47Z si vede. Il cron gira ogni 24h alle 12:35: sta
dentro la finestra per pochi secondi. Un giro saltato avrebbe reso invisibili i trade fra 48h e
24h fa, e il delta sarebbe stato attribuito al reward *esattamente come il 07/09*, con un
meccanismo diverso.
Ora `trade_copertura()` dichiara **non leggibile** (somma `None`, e `analizza` non attribuisce:
P12) in entrambi i casi — lista lunga quanto il limite (troncata) o ultima lettura oltre 24h+5min —
e il record porta `trades_motivo` (P4: il perche'). `TRADE_LIMIT = 1000` e' il `count` massimo di
Deribit. Controllo positivo nei test: 54 ordini si sommano tutti.
La riga del 07/09 in `data/live/usde_watch.jsonl` e' **corretta in loco** con campo `correzione`
(originale conservato dentro, copia del file in `usde_watch.jsonl.pre_fix_20260910`): 54 ordini
= 8 + 30 + 16 dal registro `usde_convert.jsonl`, 2.434 USDE, reward 0,080934. Il tasso non
cambia — la riga era gia' esclusa per `con_trade` — e `rendimento()` oggi da' **3,94% su 14
finestre, 12 pagate**.
## Punto 2 — `cuscino_watch`: la grandezza che nessuno guardava
Il cuscino richiesto e' `2 × 0,5 × 0,30 × equity_tot` = 30% dell'equity, **derivato** da
config e `book.WEIGHT`. La formula e' passata da `usde_convert.py` a `src/live/usde.py`, e
`usde_convert` la importa da li': due lettori, una formula (P1 — cinque occorrenze nel
progetto di sorveglianti puntati su un bersaglio ridichiarato).
| stato | criterio | azione |
|---|---|---|
| OK | slack ≥ 10% del cuscino | niente |
| PREAVVISO | 0 ≤ slack < 10% | ⚠️ alla transizione |
| SCOPERTO | slack < 0 | 🚨 + `usde_convert --quota 0,64 --esegui` (cuscino + 20% di margine, alzato dal 10% in revisione) |
| BLIND | conto non leggibile | ⚠️ alla transizione |
Guardie della riconversione: `execution_enabled` del libro (un solo interruttore), niente vendita
sotto `depeg_warn` (li' decide l'operatore, e l'allarme lo dice), un tentativo ogni 6h, le guardie
proprie di `usde_convert` (banda di prezzo, book leggibile, tetto HARD) ereditate lanciando lo
script vero (P15). Il marcatore «gia' detto» e' l'esito di `notify`: un invio fallito non consuma
la transizione (lezione del debito §5.2). Cron `:53` = dopo il giro del book (`:47`), fuori dal
minuto tondo. `monitor_health` lo sorveglia (max 3h).
📌 **Primo giro a secco (13:00Z): gia' PREAVVISO.** USDC equity $1.361,23 contro $1.335,26
richiesti: **slack +$25,97**. Il 09/09 il revisore aveva stimato +$41/+$59; la marcatura del
libro dal picco del 06/09 ne aveva consumati altri 15-30 senza che nessuno lo vedesse. E' il
caso d'uso del sorvegliante, arrivato prima del sorvegliante.
## Punto 3 — il versamento del 04/09, dal balance e non dall'equity
Il salto di equity 13:47→14:47Z diceva +$2.410,14 col mercato dell'ora dentro. Il **balance
USDC** di `balance_watch` (cambia solo per P&L realizzato, fee, funding e movimenti) dice
1.417,73455581 → 3.832,41432481 = **+$2.414,68**, nessun fill nella finestra, funding su $541
di nozionale ~$0,01. Dichiarato con banda $0,05. Il report ora lo mostra come dichiarato e
il TWR passa a **+6,66%** (era +7,22% stamattina, +7,83% il 06/09): la coda 0,63% dal 04/09 e'
marcatura delle due long, trading da arming **+$13,82**.
L'operatore ha detto che era **l'intero versamento previsto**: la voce «in attesa di importo e
data» di §1 e' chiusa. Resta la lezione di §2: per sei giorni il movimento e' stato solo
rilevato, la regola diceva il giorno stesso.
## Punto 4 — SCALA-01: la data era il punto 5 della SPEC scambiato per il gate
«Non prima del 2026-10-01» erano i 30 giorni di sorvegliante del punto 5 (§7 della SPEC); A2
chiede **≥180 giorni** col criterio passato ogni giorno, e il criterio lo misura solo
`scale_watch`, attivo dal 01/09. Le tre letture possibili, con pro e contro, sono nel diario di
sessione; l'operatore ha scelto **dal 01/09 → 2027-02-28**. Contare dall'arming avrebbe dato
un criterio ricostruito a posteriori, che non prova nulla sul sorvegliante; ridurre A2 a 30 giorni
avrebbe allentato un gate senza un costo d'attesa che lo giustifichi (il gradino 1,25x vale meno
di €100/mese di versamento).
Riconciliata anche la data d'arming: **20/06 = TP01 da solo** (commit `4650aa7`), **23/06 = il
BOOK** (commit `db738bc`, prima lettura di equity 23/06 22:00Z). CLAUDE.md §0 diceva solo la
prima, `config/live.json` e il TWR usano la seconda: erano due eventi, non un errore.
## Punto 5 — PREVDAY-01 aveva la data; non l'aveva la tabella
`RESULTS-0822` §54: decisione **2027-06-21**, kill, veto. La tabella §4 diceva «scritto 23/08 ·
10 condizioni», e il revisore ha letto quella. Ora la riga ha data, kill, veto e stato (5/7:
(a) e (b) sono strutturali, nessun forward le cambia).
Cablato in `paper_prevday.py` (prima esisteva solo nel testo, a differenza di `paper_dvolspread`):
| misura | valore al 10/09 |
|---|---|
| Sharpe forward **giornaliero** (lente del kill; l'oraria +1,19 non si cita, §54) | **+0,95** su 81 giorni, 81 attivi |
| kill (< 0,50 su ≥180 g attivi) | **NON MATURO**, leggibile dal ~18/12/2026 |
| barre ricostruibili dal feed di oggi | **1.942/1.943** (99,9%); l'unica divergente e' il 26/08 00:00, il giorno del fix di `advance()` |
| veto | ok |
⚠️ **Errore mio, catturato prima di committare:** la prima stesura del veto leggeva «divergenze
non crescenti» come `tasso_recente > tasso_totale`, e con UNA barra divergente negli ultimi 30
giorni (0,03/g contro 0,01/g) dichiarava VETO. Una guardia piu' stretta del contratto produce
allarmi che si impara a ignorare (P14): ora la crescita richiede **≥5 barre recenti E tasso
doppio**, dichiarato nel codice e provato nel test con i numeri della serie vera.
La ricostruzione al 99,9% contro il 95,6% di §54: la misura del 23/08 era stata fatta *prima*
del fix di `advance()` del 26/08 (le 67 barre divergenti erano «all'ora del cron», cioe' barre
parziali); oggi il feed rivisto e la serie coincidono tranne quella barra.
## Cose trovate per strada
- Due timestamp «di comodo» scritti a mano (13:05Z e 13:07Z) invece dell'ora letta (12:57:43Z e
13:04:09Z): corretti subito. E' esattamente la regola globale «la data si LEGGE» — vale anche per
chi la scrive nel campo `correzione`.
- `issue --json` va **prima** del sottocomando; il percorso nella regola globale era
`/opt/docker/AI-OS` invece di `/opt/AI-OS` (corretto la mattina, nel test dello strumento).
## Verifica
`uv run pytest`: **1060 passati** (erano 1031). Nuovi: `test_cuscino_watch.py` (14),
`test_paper_prevday_gate.py` (8), +5 in `test_usde_watch.py`. Revisione del diff affidata a
`fable` (agente fresco): esito e correzioni applicate in coda a questo diario.
## Revisione `fable` (13:10-13:18Z): 15 segnalazioni, 4 medie — tutte verificate, 12 applicate
| # | segnalazione | verifica | esito |
|---|---|---|---|
| 1 | `puo_riconvertire` contava anche i NON tentativi (`tentata=False`): un giro `--secco` o un prezzo illeggibile bloccava la riconversione vera per 6h | riprodotta | filtro su `tentata` + test |
| 2 | il tetto del venue in `usde_convert.piano` bloccava anche la **vendita**: con `venue_cap_frac` rimesso in config la riconversione automatica sarebbe stata morta | riprodotta (`side=sell`, `ok=False`) | vale solo in acquisto |
| 3 | `MARGINE_RIPRISTINO` 10% == `PREAVVISO_FRAC` 10%: dopo ogni 🚨 un ⚠️ per costruzione (P14) | riprodotta col floor di 1 USDE | margine **20%** → quota **0,64**, test |
| 4 | `--secco` scriveva nel registro vivo (veicolo del punto 1, e maschera un cron fermo) | riprodotta (riga 13:00:15Z) | non scrive; riga tolta |
| 5 | due bersagli di quota: `quota_target` 0,70 in config contro 0,64 del ripristino; il ripristino e' un **ratchet verso il basso** | letto | dichiarato in CLAUDE.md §5.17 e nel docstring: decisione dell'operatore (N9) |
| 7 | buco di ≤5 min dentro la tolleranza della finestra 24h | letto | dichiarato (D5) + fonte Deribit documentata |
| 8 | `giorni_attivi` legge posizioni REAL, il kill e' MODELED | letto | dichiarato nel docstring (81/81 oggi) |
| 10 | il test del cron leggeva un commento nello `.sh`, non la crontab | letto | legge `crontab -l` come `test_book_cadenza` |
| 11 | `prevday_target` patchato senza `monkeypatch` | letto | `monkeypatch` |
| 13 | «QUATTRO movimenti» in §2: sono TRE, in quattro tratti | letto | corretto |
| 14 | «5/7» contro «8/10» di §54: base diversa non dichiarata | letto | dichiarata |
| 6, 9, 12, 15 | verifiche che reggono (`sys.executable`, `cwd`, `piano()` in SCOPERTO, ricostruzione ≡ `advance`) | — | — |
Non applicato: il lock fra cron e conversione a mano (DUBBIO 2) — dichiarato come limite nel
docstring. Il DUBBIO 1 (ratchet) e' la domanda aperta per l'operatore.
+3
View File
@@ -8,6 +8,9 @@
> `data/live/scale_history.jsonl`, `tests/test_book_scale.py` (T1-T11, **18 test verdi**). > `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, > 🚨 **`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`). > vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`).
> 📌 **(10/09, issue #5) A2 conta dal 2026-09-01**, primo giorno di `scale_watch`: 180 giorni cadono il
> **2027-02-28**, ed e' la data «non prima del» del gate in CLAUDE.md §4. Il «2026-10-01» scritto il 01/09 era
> il punto 5 (30 giorni di sorvegliante) scambiato per la data del gate: incompatibile con A2 per costruzione.
> **Restano i punti 5-7:** ≥30 giorni a 1,00 col sorvegliante attivo, poi `GATE SCALA-01`, poi > **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 > `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. > sorvegliante e' rotto mentre la leva e' ancora 1,00.
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Sorveglianza ORARIA del cuscino USDC di regolamento (debito §5.17, issue #3) — v2.0.0+.
# Minuto :53 = DOPO il giro del book (:47, i cui fill si regolano in USDC) e fuori dal minuto
# tondo (rate-limit per-IP), dal :25 (catena), dal :35 (usde) e dal :42 (balance).
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
# stesso del libro (execution_enabled in config/live.json).
export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
{
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
uv run python scripts/live/cuscino_watch.py --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_cuscino.log 2>&1
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python
"""cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento. PUO' INVIARE ORDINI.
PERCHE' ESISTE (debito §5.17, issue #3). Dal 06/09 il 68,7% dell'equity e' USDE. P&L e funding dei
perp USDC-lineari si regolano in USDC: una perdita del libro consuma il cuscino e fa salire la quota
USDE DA SOLA, senza un ordine. `usde_watch` allerta solo su `quota > quota_max_frac` (0,85): a
68,7% lo slack sul cuscino era ~$41-58, e un saldo USDC negativo costa lo 0,05%/GIORNO (18,25%/anno,
4,3x la resa che l'USDE compra). Nessuno guardava la grandezza giusta.
LA MISURA (decisione dell'operatore, 10/09): l'EQUITY USDC del conto — balance piu' P&L non
realizzato — perche' e' quella che risponde del regolamento; il balance vedrebbe il buco solo a
trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/book.py tramite
`usde.cuscino_richiesto_usd` (n_asset x frac x disaster_sl_pct x equity totale): la stessa formula
che `usde_convert` applica a ogni piano — un sorvegliante che ridichiara il proprio bersaglio non
sta controllando niente (P1, 5 occorrenze nel progetto).
TRE STATI, TRE AZIONI (P5: distinguere anche quando l'azione e' la stessa):
* OK — slack >= PREAVVISO_FRAC x cuscino: niente.
* PREAVVISO — 0 <= slack < 10% del cuscino: ⚠️ alla transizione. E' latenza comprata apposta:
un'ora in perdita non deve trovare il cuscino gia' scoperto.
* SCOPERTO — slack < 0: 🚨 alla transizione, e RICONVERSIONE AUTOMATICA (decisione
dell'operatore, 10/09): lancia `usde_convert --quota q --esegui` con
q = usde.quota_ripristino(MARGINE_RIPRISTINO), cioe' la quota che lascia il cuscino
piu' un 20% di margine, cosi' non si torna sul bordo alla prima ora in perdita.
Vende USDE contro USDC (~6 bps di spread, fee 0).
* BLIND — conto non leggibile: ⚠️ alla transizione. "Non vedo" non e' "va tutto bene".
LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui):
* `execution_enabled` in config/live.json deve essere true — lo stesso interruttore del libro:
disarmare il libro disarma anche questo, senza un secondo posto da ricordare.
* NON si riconverte sotto `depeg_warn`: vendere USDE sotto la pari cristallizza la perdita del
collaterale; li' decide l'operatore, e l'allarme lo dice.
* al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h): un tentativo fallito che si ripete ogni
ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14).
* `usde_convert` porta le proprie guardie (banda di prezzo, book leggibile, tetto HARD sulla
quota): questo script NON le duplica, le eredita lanciando lo script vero (P15: la regola si
prova contro il codice che la esegue).
* `--secco`: niente ordini, niente Telegram, NIENTE riga nel registro: stampa cosa FAREBBE.
* niente lock: un `usde_convert --esegui` lanciato a mano nello stesso minuto del cron (:53)
puo' vendere due volte verso bersagli diversi. Limite dichiarato (D5), non riparato.
LIMITE DICHIARATO — IL RIPRISTINO E' UN RATCHET VERSO IL BASSO. Dopo una riconversione la quota
resta a `quota_ripristino` (~0,64) e nessuno la riporta a `usde.quota_target` (0,70): ricomprare
USDE e' una decisione dell'operatore (`usde_convert --quota 0.70`), non un automatismo di questo
script. E a 0,70 lo slack e' ZERO per costruzione (§1: «coincidenza per costruzione, zero slack»),
quindi PREAVVISO e' lo stato NORMALE del conto a quota piena — allerta una volta, alla transizione.
TRASPORTO (lezione del debito §5.2): il marcatore "gia' detto" e' l'esito di `notify` scritto nel
record (`allertato`); se l'invio fallisce la transizione si ripete al giro dopo invece di andare
persa per l'episodio intero.
La serie data/live/cuscino_watch.jsonl e' dentro il perimetro di backup ed e' sorvegliata da
monitor_health (un watch fermo = nessuno guarda il cuscino, e il silenzio si legge come zero).
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
"""
from __future__ import annotations
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live import usde as U # noqa: E402
from src.live.notifier import notify # noqa: E402
STATE = ROOT / "data" / "live" / "cuscino_watch.jsonl"
CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
PREAVVISO_FRAC = 0.10 # sotto il 10% di slack (in frazione del cuscino) si preavvisa
MARGINE_RIPRISTINO = 0.20 # la riconversione lascia cuscino x 1,20: DOPPIO del preavviso, cosi'
# dopo il ripristino lo stato e' OK e non PREAVVISO per costruzione
# (revisione fable 10/09: a 0,10 == PREAVVISO_FRAC il floor di
# usde_convert lasciava il conto sotto la soglia)
RIPRISTINO_MIN_ORE = 6.0 # un tentativo al massimo ogni 6 ore
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min
def leggi(path: Path = STATE) -> list[dict]:
if not path.exists():
return []
out = []
for ln in path.read_text().splitlines():
ln = ln.strip()
if ln:
out.append(json.loads(ln))
return out
def giudica(eq_usdc: float | None, equity_tot: float | None, cuscino: float | None,
preavviso_frac: float = PREAVVISO_FRAC) -> dict:
"""PURA. -> stato, slack, soglia di preavviso. `None` in ingresso = BLIND."""
if eq_usdc is None or equity_tot is None or cuscino is None:
return dict(stato="BLIND", slack=None, preavviso_usd=None)
slack = eq_usdc - cuscino
preavviso = preavviso_frac * cuscino
if slack < 0:
stato = "SCOPERTO"
elif slack < preavviso:
stato = "PREAVVISO"
else:
stato = "OK"
return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2))
def transizione(prev: dict | None, stato: str) -> bool:
"""PURA. Si allerta se lo stato e' cambiato, O se l'ultima allerta per questo stato non e'
partita (`allertato` False): un invio fallito non consuma la transizione (debito §5.2)."""
if stato == "OK":
return False
if prev is None or prev.get("stato") != stato:
return True
return not bool(prev.get("allertato"))
def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: bool,
px: float | None, depeg_warn: float,
min_ore: float = RIPRISTINO_MIN_ORE) -> tuple[bool, str]:
"""PURA. Le guardie della riconversione automatica. -> (si', motivo se no)."""
if not execution_enabled:
return False, "execution_enabled=false in config/live.json (il libro e' disarmato: anche questo)"
if px is None:
return False, "prezzo USDE non leggibile: non si vende al buio (P5)"
if px < depeg_warn:
return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: vendere ora cristallizza la perdita — decide l'operatore"
# contano solo i tentativi VERI (subprocess lanciato): un record con `tentata=False` — prezzo
# illeggibile, --secco, interruttore spento — non consuma il budget (revisione fable 10/09)
ultimo = [r for r in prev_records if (r.get("riconversione") or {}).get("tentata")]
if ultimo:
ore = (now_ms - int(ultimo[-1]["ts"])) / 3_600_000
if ore < min_ore:
return False, f"ultimo tentativo {ore:.1f}h fa (< {min_ore:.0f}h): non si insiste ogni ora"
return True, ""
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3)."""
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
except subprocess.TimeoutExpired:
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:]))
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:]))
def _safe_client():
try:
from src.live.deribit import DeribitRead
return DeribitRead()
except Exception:
return None
def _execution_enabled() -> bool:
try:
return bool(json.loads((ROOT / "config" / "live.json").read_text()).get("execution_enabled"))
except Exception:
return False
def main() -> int:
quiet, secco = "--quiet" in sys.argv, "--secco" in sys.argv
c = U.cfg()
records = leggi()
prev = records[-1] if records else None
now = datetime.now(timezone.utc)
now_ms = int(now.timestamp() * 1000)
client = _safe_client()
eq_usdc = eq_usde = None
motivo_blind = None
if client is None:
motivo_blind = "gateway non raggiungibile"
else:
try:
eq_usdc = float(client.account_summary("USDC")["equity"])
except Exception as e:
motivo_blind = f"conto USDC non leggibile ({type(e).__name__})"
try:
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
except Exception as e:
motivo_blind = motivo_blind or f"conto USDE non leggibile ({type(e).__name__})"
px, px_fonte = U.prezzo(client)
equity_tot = cuscino = come = None
if eq_usdc is not None and eq_usde is not None:
usd, _ = U.valuta(eq_usde, px)
equity_tot = eq_usdc + usd
cuscino, come = U.cuscino_richiesto_usd(equity_tot)
g = giudica(eq_usdc, equity_tot, cuscino)
stato = g["stato"]
rec = dict(ts=now_ms, data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato,
motivo_blind=motivo_blind, eq_usdc=eq_usdc, eq_usde=eq_usde, px=px,
px_fonte=px_fonte, equity_tot=equity_tot, cuscino=cuscino, cuscino_come=come,
slack=g["slack"], preavviso_usd=g["preavviso_usd"], allertato=None,
riconversione=None, secco=secco)
# --- riconversione automatica: solo SCOPERTO, solo se le guardie lo permettono ---
if stato == "SCOPERTO":
ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"])
quota = U.quota_ripristino(MARGINE_RIPRISTINO)
if not ok:
rec["riconversione"] = dict(tentata=False, quota=quota, motivo=perche)
elif secco:
rec["riconversione"] = dict(tentata=False, quota=quota,
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui")
else:
esito = riconverti(quota)
rec["riconversione"] = dict(tentata=True, quota=quota, **esito)
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
if transizione(prev, stato):
if secco:
rec["allertato"] = False
elif stato == "SCOPERTO":
r = rec["riconversione"] or {}
azione = ("riconversione " + ("ESEGUITA" if r.get("ok") else "FALLITA")
+ f" (quota -> {r.get('quota', 0):.0%})" if r.get("tentata")
else f"NON riconvertito: {r.get('motivo')}")
rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", {
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})",
"slack": f"${g['slack']:+,.2f}", "azione": azione,
"nota": "un USDC negativo costa 0,05%/giorno; il libro si regola in USDC"}, tentativi=3)
elif stato == "PREAVVISO":
rec["allertato"] = notify("⚠️ cuscino USDC in esaurimento", {
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
"slack": f"${g['slack']:+,.2f} (< {PREAVVISO_FRAC:.0%} del cuscino)",
"nota": f"sotto zero riconverte da solo a quota {U.quota_ripristino(MARGINE_RIPRISTINO):.0%}"})
elif stato == "BLIND":
rec["allertato"] = notify("⚠️ cuscino_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
elif stato == "SCOPERTO" and rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
# non e' una transizione ma e' un ordine mandato: si dice sempre
r = rec["riconversione"]
rec["allertato"] = notify("📌 cuscino USDC: riconversione " + ("eseguita" if r["ok"] else "FALLITA"),
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
# --secco e' secco ANCHE sul registro: un giro a mano non deve diventare il `prev` del cron,
# ne' far sembrare vivo un monitor fermo (revisione fable 10/09)
if not secco:
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
cambiato = prev is None or prev.get("stato") != stato or rec["riconversione"] is not None
if not quiet or cambiato:
print("=" * 78)
print(f" CUSCINO WATCH — {rec['data']} {'(SECCO)' if secco else ''}")
print("=" * 78)
if stato == "BLIND":
print(f" stato : BLIND — {motivo_blind}")
else:
print(f" conto : USDC equity ${eq_usdc:,.2f} + USDE {eq_usde:,.2f} @ {px if px else 'n/d'}"
f" ({px_fonte}) = ${equity_tot:,.2f}")
print(f" cuscino : ${cuscino:,.2f} richiesti ({come})")
print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f})")
print(f" stato : {stato}")
if rec["riconversione"]:
r = rec["riconversione"]
print(f" riconvers. : {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}"
+ (f"{r.get('motivo')}" if r.get("motivo") else ""))
for ln in r.get("coda", []):
print(f" {ln}")
if rec["allertato"] is not None:
print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}")
return 0
USO = """uso: cuscino_watch.py [--quiet] [--secco]
cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento; sotto zero riconverte.
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("cuscino_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
sys.exit(main())
+163 -1
View File
@@ -53,6 +53,21 @@ MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
REAL_CAPITAL = 600.0 # capitale mainnet reale REAL_CAPITAL = 600.0 # capitale mainnet reale
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
# --- GATE PRE-REGISTRATO `GATE PREVDAY-01` (2026-08-23, RESULTS-0822 §54) -----------------------
# Cablato qui il 2026-09-10 (issue #4): esisteva solo nel testo, e la revisione del 09/09 lo ha
# letto come "gate senza data". Decisione dell'operatore: tenere la data, cablare kill e veto.
GATE_DATE = "2027-06-21" # decisione piena: 7 condizioni (a)-(g), tutte necessarie
KILL_SHARPE = -0.50 # kill: Sharpe forward < -0,50 ...
KILL_MIN_ACTIVE_DAYS = 180 # ... su >= 180 giorni di barre ATTIVE (posizione != 0)
MIN_RECON_FRAC = 0.80 # veto d'integrita': >= 80% di barre ricostruibili dal feed
RECON_TOL = 1.5e-6 # net_modeled e' registrato a 6 decimali: mezzo ulp di tolleranza
RECENTI_GIORNI = 30 # "i minuti non registrati non devono crescere": ultimi 30g vs tutto
MIN_DIV_CRESCITA = 5 # ... ma UNA barra divergente su 1943 non e' una crescita (P14): la
# crescita richiede >= 5 divergenti recenti E tasso doppio del totale
# LENTE: il kill si misura sullo Sharpe GIORNALIERO (somma delle barre orarie per giorno UTC),
# la lente con cui e' misurato ogni altro sleeve del progetto — §54: sulla lente ORARIA lo stesso
# forward vale +2,04 contro +1,56 giornaliero. Al kill la famiglia NON si ri-ottimizza.
def build_bars() -> dict[str, pd.DataFrame]: def build_bars() -> dict[str, pd.DataFrame]:
return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS} return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
@@ -145,6 +160,151 @@ def advance(st: dict, dfs: dict) -> dict:
return st return st
def _returns_df() -> pd.DataFrame | None:
if not RETURNS_FILE.exists():
return None
r = pd.read_json(RETURNS_FILE, lines=True)
if r.empty:
return None
r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
return r
def sharpe_giornaliero(r: pd.DataFrame, col: str = "net_modeled") -> tuple[float, int]:
"""PURA. Sharpe annualizzato sui rendimenti GIORNALIERI (somma delle barre orarie per giorno
UTC) -> (sharpe, n_giorni). nan sotto 30 giorni o a varianza nulla."""
d = r.groupby(r["dt"].dt.floor("D"))[col].sum()
if len(d) < 30 or d.std() == 0:
return float("nan"), int(len(d))
return float(d.mean() / d.std() * np.sqrt(365.25)), int(len(d))
def giorni_attivi(r: pd.DataFrame) -> int:
"""PURA. Giorni UTC con almeno una barra a posizione != 0 (il kill conta le barre ATTIVE).
le posizioni registrate sono quelle del libro REAL-$600 (advance scrive `pr`), lo Sharpe
del kill e' MODELED: divergono solo se un ingresso da 0 vale < $5 di nozionale, cosa che a
vol-target 20% non succede (10/09: 81/81 giorni). Dichiarato, non riparato (D5)."""
att = r[(r["pos_btc"] != 0) | (r["pos_eth"] != 0)]
return int(att["dt"].dt.floor("D").nunique())
def kill_check(sharpe: float, n_attivi: int, today: pd.Timestamp) -> tuple[str, str]:
"""PURA. -> (stato, motivo). KILL solo con >= 180 giorni attivi E Sharpe < -0,50 (§54):
a quell'orizzonte P(uccidere un edge vivo a Sharpe 1,2) = 9,7%."""
if n_attivi < KILL_MIN_ACTIVE_DAYS:
return "NON_MATURO", (f"{n_attivi} giorni attivi < {KILL_MIN_ACTIVE_DAYS}: il kill non e' "
f"leggibile (mancano {KILL_MIN_ACTIVE_DAYS - n_attivi} giorni)")
if not np.isfinite(sharpe):
return "NON_MISURABILE", "Sharpe forward non misurabile"
if sharpe < KILL_SHARPE:
return "KILL", f"Sharpe {sharpe:+.2f} < {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi -> RITIRARE, senza ri-ottimizzare"
return "SUPERATO", f"Sharpe {sharpe:+.2f} >= {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi"
def ricostruzione(r: pd.DataFrame, dfs: dict, start_ts: int) -> dict:
"""Rigioca la strategia CONGELATA sul feed di OGGI dallo `start_ts` del monitor e confronta
barra per barra `net_modeled` registrato con quello ricalcolato (ribilanciamento continuo,
la stessa aritmetica di `advance`). Una barra e' RICOSTRUIBILE se coincide entro RECON_TOL.
Cosa misura: quante barre registrate sono riproducibili dal dato certificato. Le divergenti
sono barre calcolate su un feed che il rebuild ha poi rivisto (§54: 67 su 1512, tutte
all'ora del cron) — e' la misura del veto d'integrita' (>= 80%, e i minuti persi non devono
crescere: qui, divergenti/giorno negli ultimi 30g contro l'intera finestra)."""
data = {}
for a in ASSETS:
df = dfs[a]
c = df["close"].values.astype(float)
rr = np.zeros(len(c)); rr[1:] = c[1:] / c[:-1] - 1.0
data[a] = dict(ts=df["timestamp"].values.astype("int64"), r=rr, tgt=prevday_target(df))
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
# posizione iniziale come init_state: target all'ultima barra <= start_ts
pos = {}
for a in ASSETS:
i0 = max(i for t, i in idx[a].items() if t <= start_ts)
pos[a] = float(data[a]["tgt"][i0])
ricalc, presenti = [], []
for t in r["ts"].astype("int64"):
t = int(t)
if any(t not in idx[a] for a in ASSETS):
ricalc.append(np.nan); presenti.append(False); continue
net = 0.0
for a in ASSETS:
i = idx[a][t]
tgt = float(data[a]["tgt"][i])
net += WEIGHT * (pos[a] * float(data[a]["r"][i]) - FEE_SIDE * abs(tgt - pos[a]))
pos[a] = tgt
ricalc.append(net); presenti.append(True)
ricalc = np.asarray(ricalc, dtype=float)
reg = r["net_modeled"].values.astype(float)
ok = np.isfinite(ricalc) & (np.abs(ricalc - reg) <= RECON_TOL)
n = len(reg)
giorni = r["dt"].dt.floor("D")
span_g = max(1, int(giorni.nunique()))
recenti = r["dt"] >= (r["dt"].max() - pd.Timedelta(days=RECENTI_GIORNI))
div_rec = int((~ok[recenti.values]).sum())
g_rec = max(1, int(giorni[recenti].nunique()))
return dict(n=n, ricostruibili=int(ok.sum()), frac=float(ok.sum() / n) if n else float("nan"),
assenti_nel_feed=int((~np.asarray(presenti)).sum()),
div_per_giorno=float((n - ok.sum()) / span_g),
div_recenti=div_rec, div_per_giorno_recenti=float(div_rec / g_rec),
divergenti_ts=[int(t) for t in r["ts"].values[~ok]][:20])
def veto_check(frac: float, div_g_tot: float, div_g_rec: float, div_rec: int) -> tuple[bool, str]:
"""PURA. Veto d'integrita' (blocca, non decide): sotto l'80% di barre ricostruibili, o con le
divergenze che CRESCONO negli ultimi 30 giorni, la finestra si ESTENDE invece di decidere."""
motivi = []
if not np.isfinite(frac) or frac < MIN_RECON_FRAC:
motivi.append(f"barre ricostruibili {frac:.1%} < {MIN_RECON_FRAC:.0%}")
if div_rec >= MIN_DIV_CRESCITA and div_g_rec > 2.0 * div_g_tot + 1e-12:
motivi.append(f"divergenze in crescita: {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g sull'intera finestra)")
return (not motivi), ("; ".join(motivi) if motivi else
f"ricostruibili {frac:.1%}, divergenti {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g totale)")
def print_gate(st: dict, dfs: dict, today: pd.Timestamp | None = None) -> dict:
"""Stampa lo stato del gate pre-registrato e lo RITORNA (per i test e per chi legge il log).
Non decide niente: al kill e alla data stampa cosa la regola dice, in maiuscolo."""
r = _returns_df()
out = dict(gate_date=GATE_DATE, kill="NON_MISURABILE", veto_ok=None)
print(f"\n GATE PRE-REGISTRATO `PREVDAY-01` (23/08, RESULTS-0822 §54) — decisione {GATE_DATE}")
if r is None or len(r) < 2:
print(" serie forward assente o troppo corta: niente da misurare")
return out
today = today or pd.Timestamp.now(tz="UTC").normalize()
sh, n_g = sharpe_giornaliero(r)
n_att = giorni_attivi(r)
stato, motivo = kill_check(sh, n_att, today)
out.update(sharpe_giornaliero=sh, n_giorni=n_g, n_attivi=n_att, kill=stato, kill_motivo=motivo)
print(f" Sharpe forward GIORNALIERO (MODELED): {sh:+.2f} su {n_g} giorni, {n_att} attivi"
f" [lente oraria {_sharpe_orario(r):+.2f}: non si cita, §54]")
if stato == "KILL":
print(f" *** KILL: {motivo} ***")
else:
print(f" kill (Sharpe < {KILL_SHARPE:+.2f} su >= {KILL_MIN_ACTIVE_DAYS}g attivi): {stato}{motivo}")
rc = ricostruzione(r, dfs, int(st["start_ts"]))
ok, vm = veto_check(rc["frac"], rc["div_per_giorno"], rc["div_per_giorno_recenti"], rc["div_recenti"])
out.update(ricostruzione=rc, veto_ok=ok, veto_motivo=vm)
print(f" veto d'integrita': {'ok' if ok else '*** VETO — la finestra si ESTENDE ***'}{vm}"
f" ({rc['ricostruibili']}/{rc['n']} barre, {rc['assenti_nel_feed']} assenti nel feed)")
if today >= pd.Timestamp(GATE_DATE, tz="UTC"):
print(f" *** DECISIONE DOVUTA ({GATE_DATE}): (a) DSR >= 0,95 sulla famiglia dichiarata "
f"(b) cella al buio == congelata (c) delta libro > +0,05 col funding (d) weights_tilt_null "
f"(e) ADDS+robust_oos+non-hedge sulla cella al buio (f) day_boundary_robust "
f"(g) Sharpe forward > 0 [{sh:+.2f}] — TUTTE necessarie ***")
else:
print(f" decisione fra {(pd.Timestamp(GATE_DATE, tz='UTC') - today).days} giorni; "
f"le condizioni (a)-(b) sono STRUTTURALI e il forward non le cambia (§54)")
return out
def _sharpe_orario(r: pd.DataFrame) -> float:
x = r["net_modeled"].astype(float)
return float(x.mean() / x.std() * np.sqrt(24 * 365.25)) if x.std() > 0 else float("nan")
def print_status(st: dict, dfs: dict): def print_status(st: dict, dfs: dict):
days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000 days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000
rm = st["cap_modeled"] / MODELED_CAPITAL - 1 rm = st["cap_modeled"] / MODELED_CAPITAL - 1
@@ -156,7 +316,9 @@ def print_status(st: dict, dfs: dict):
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%") print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%") print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)") print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)")
print(f" log: {RETURNS_FILE}\n") print(f" log: {RETURNS_FILE}")
print_gate(st, dfs)
print()
def main(): def main():
+7 -10
View File
@@ -88,15 +88,10 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
# questo epsilon il rumore float boccia il caso di pareggio. # questo epsilon il rumore float boccia il caso di pareggio.
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]: # Il cuscino di regolamento vive in src/live/usde.py dal 10/09 (issue #3): cuscino_watch lo
"""Cuscino USDC di REGOLAMENTO = disaster-SL sulla massima esposizione lorda del libro. # sorveglia ogni ora e questo script lo rispetta a ogni piano — due lettori, UNA formula (P1).
Derivato da config/live.json + src/live/book.py, mai ridichiarato qui.""" # L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cfg = json.loads((ROOT / "config" / "live.json").read_text()) cuscino_richiesto_usd = U.cuscino_richiesto_usd
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B
frac, n_asset = float(B.WEIGHT), 2
lordo_max = equity_tot * frac * n_asset
return lordo_max * sl, f"{n_asset} asset x frac {frac} x SL {sl:.0%} su ${equity_tot:,.2f}"
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]: def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
@@ -165,7 +160,9 @@ def piano(eq_usdc: float, eq_usde: float, px: float, quota_target: float,
motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})") motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})")
if abs(delta_usd) < MIN_ORDER_USD: if abs(delta_usd) < MIN_ORDER_USD:
motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}") motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}")
if cap_usd is not None and val_target > cap_usd + EPS_CUSCINO: # il tetto del venue limita l'ACQUISTO di USDE: una VENDITA va sempre verso il tetto, e bloccarla
# renderebbe morta la riconversione automatica di cuscino_watch (revisione fable 10/09)
if side == "buy" and cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
motivi.append( motivi.append(
f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} " f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} "
f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). " f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). "
+54 -11
View File
@@ -66,6 +66,25 @@ VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conc
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
# (1) `count` massimo di Deribit per get_user_trades_by_instrument: 1000. Il 07/09 il lettore
# chiedeva limit=50 e la sonda del 06/09 aveva fatto 54 ordini: i 4 non letti (400 USDE)
# sono finiti nel «reward» (issue #2). Se il venue restituisce ESATTAMENTE `limit` righe la
# lista puo' essere troncata e la somma NON e' leggibile (P12: non si attribuisce).
# (2) il gateway chiama l'endpoint senza `historical`/timestamp: Deribit restituisce solo le
# ultime 24h — DOCUMENTATO ("Accessing historical trades and orders using API": recent
# trades 24h, orders 30 min, count max 1000) e misurato (il fill 08/09 06:47Z invisibile
# alle 12:55Z del 10/09, quello del 09/09 21:47Z visibile). Se l'ultima lettura e' piu'
# vecchia della finestra, i trade fra la lettura e il bordo sono INVISIBILI e la somma non
# e' leggibile.
# ⚠️ BUCO DICHIARATO (D5): il cron gira ogni 24h + pochi secondi; con la tolleranza di 5 min
# i trade nei primi ≤5 min dopo la lettura precedente sarebbero invisibili e sommati come
# «leggibile». Innocuo: nessun attrezzo converte USDE alle 12:35 UTC, e senza tolleranza il
# giro quotidiano sarebbe SEMPRE «non leggibile» e il tasso non avrebbe mai finestre nuove.
TRADE_LIMIT = 1000
FINESTRA_GATEWAY_MS = 24 * 3_600_000
TOLL_FINESTRA_MS = 5 * 60_000
def _parse(ts: str) -> datetime: def _parse(ts: str) -> datetime:
return datetime.fromisoformat(ts.replace("Z", "+00:00")) return datetime.fromisoformat(ts.replace("Z", "+00:00"))
@@ -177,14 +196,36 @@ def _safe_client():
return None return None
def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]: def trade_copertura(n_righe: int, prev_ts_ms: int, now_ms: int,
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile.""" limit: int = TRADE_LIMIT, finestra_ms: int = FINESTRA_GATEWAY_MS,
toll_ms: int = TOLL_FINESTRA_MS) -> str | None:
"""PURA. None se la lista di trade COPRE l'intervallo (prev, now]; altrimenti il motivo per cui
NON lo copre e allora la somma non si usa (P12: un reward calcolato su una lista incompleta
e' un'invenzione, il 07/09 valeva 400 USDE)."""
if n_righe >= limit:
return f"lista troncata: {n_righe} righe = limite {limit} del venue"
if now_ms - prev_ts_ms > finestra_ms + toll_ms:
ore = (now_ms - prev_ts_ms) / 3_600_000
return (f"ultima lettura {ore:.1f}h fa, oltre la finestra di {finestra_ms / 3_600_000:.0f}h "
f"del gateway: i trade piu' vecchi sono invisibili")
return None
def _trades_spot_da(client, ts_ms: int, now_ms: int | None = None
) -> tuple[float | None, int | None, str | None]:
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms.
-> (somma, n, motivo). somma None = non leggibile, e `motivo` dice perche' (P4)."""
if client is None: if client is None:
return None, None return None, None, "gateway non raggiungibile"
try: try:
rows = client.trade_history(U.SPOT, limit=50) rows = client.trade_history(U.SPOT, limit=TRADE_LIMIT)
except Exception: except Exception as e:
return None, None return None, None, f"trade_history: {type(e).__name__}"
if now_ms is None:
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
motivo = trade_copertura(len(rows), ts_ms, now_ms)
if motivo is not None:
return None, None, motivo
tot, n = 0.0, 0 tot, n = 0.0, 0
for t in rows: for t in rows:
if int(t.get("timestamp") or 0) <= ts_ms: if int(t.get("timestamp") or 0) <= ts_ms:
@@ -192,7 +233,7 @@ def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
amt = float(t.get("amount") or 0) amt = float(t.get("amount") or 0)
tot += amt if (t.get("direction") or "").lower() == "buy" else -amt tot += amt if (t.get("direction") or "").lower() == "buy" else -amt
n += 1 n += 1
return round(tot, 8), n return round(tot, 8), n, None
def main() -> int: def main() -> int:
@@ -221,10 +262,11 @@ def main() -> int:
if eq_usde is not None: if eq_usde is not None:
usd, _nota = U.valuta(eq_usde, px) usd, _nota = U.valuta(eq_usde, px)
trades_usde, n_trades = (None, None) trades_usde, n_trades, trades_motivo = (None, None, None)
an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
if stato == "OK" and prev is not None and prev.get("eq_usde") is not None: if stato == "OK" and prev is not None and prev.get("eq_usde") is not None:
trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"])) trades_usde, n_trades, trades_motivo = _trades_spot_da(
client, int(prev["ts"]), int(now.timestamp() * 1000))
an = analizza(prev, eq_usde, trades_usde) an = analizza(prev, eq_usde, trades_usde)
quota = None quota = None
@@ -237,7 +279,7 @@ def main() -> int:
stato=stato, motivo_blind=motivo_blind, stato=stato, motivo_blind=motivo_blind,
eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd, eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd,
eq_usdc=eq_usdc, quota=quota, eq_usdc=eq_usdc, quota=quota,
trades_usde=trades_usde, n_trades=n_trades, **an, trades_usde=trades_usde, n_trades=n_trades, trades_motivo=trades_motivo, **an,
) )
v, v_motivo = verdetto(records + [rec], now) v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
@@ -312,7 +354,8 @@ def main() -> int:
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut" f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})") f" {c['haircut']:.0%})")
if an["delta"] is not None: if an["delta"] is not None:
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
if an["reward_stimato"] is None
else f"reward stimato {an['reward_stimato']:+.6f} USDE" else f"reward stimato {an['reward_stimato']:+.6f} USDE"
+ (" [con trade nel mezzo]" if an["con_trade"] else "")) + (" [con trade nel mezzo]" if an["con_trade"] else ""))
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}") print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
+6
View File
@@ -92,6 +92,12 @@ MONITORS: tuple[MonitorSpec, ...] = (
MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0, MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0,
gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08", gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08",
open_labeled=False), open_labeled=False),
# Cuscino USDC di regolamento (cron :53, ORARIO, debito §5.17 / issue #3): se si ferma nessuno
# vede il saldo USDC scendere sotto il cuscino, e la riconversione automatica non parte.
# max_age 3h = due giri persi; timbri di esecuzione -> open_labeled=False.
MonitorSpec("cuscino_watch", "live", "cuscino_watch.jsonl", 1.0, max_age_h=3.0,
gate="cuscino USDC di regolamento: allerta + riconversione automatica",
open_labeled=False),
) )
+36
View File
@@ -93,3 +93,39 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)" return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)"
p = min(px, 1.0) p = min(px, 1.0)
return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}" return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}"
# --- CUSCINO USDC DI REGOLAMENTO ------------------------------------------------------------
# P&L e funding dei perp USDC-lineari si regolano in USDC, non nel collaterale: a quota USDE alta
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
# (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e'
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
# n_asset x frac x disaster_sl_pct x equity_totale
# tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
# e usde_convert pure — due lettori, una formula.
N_ASSET = 2
def frazione_cuscino() -> tuple[float, str]:
"""PURA sulla config. -> (frazione dell'equity totale che deve restare in USDC, come)."""
cfg = json.loads(CFG_PATH.read_text())
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B # lazy: book importa shadow, che importa questo modulo
frac = float(B.WEIGHT)
return N_ASSET * frac * sl, f"{N_ASSET} asset x frac {frac} x SL {sl:.0%}"
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
"""Cuscino USDC di REGOLAMENTO in dollari, per un'equity totale data. -> (usd, come)."""
f, come = frazione_cuscino()
return equity_tot * f, f"{come} su ${equity_tot:,.2f}"
def quota_ripristino(margine_frac: float) -> float:
"""PURA sulla config. Quota USDE che lascia il cuscino PIU' un margine (frazione del cuscino):
e' il bersaglio della riconversione automatica quando il cuscino e' scoperto. Il margine serve
a non tornare sul bordo alla prima ora in perdita (P6: la persistenza e' latenza)."""
f, _ = frazione_cuscino()
return max(0.0, 1.0 - f * (1.0 + margine_frac))
+171
View File
@@ -0,0 +1,171 @@
"""cuscino_watch: il cuscino USDC di regolamento e' sorvegliato, e sotto zero si riconverte.
Blindano: (a) il cuscino e' DERIVATO da config + book (P1), la stessa formula di usde_convert;
(b) i tre stati e la soglia di preavviso; (c) la transizione non si consuma se l'invio fallisce
(debito §5.2); (d) le guardie della riconversione automatica interruttore del libro, depeg,
cadenza e la quota di ripristino che lascia margine."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import usde as U # noqa: E402
def _carica(nome):
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
W = _carica("cuscino_watch")
# ------------------------------ il cuscino e' derivato, non ridichiarato ------------------------------
def test_cuscino_derivato_da_config_e_book():
cfg = json.loads((ROOT / "config" / "live.json").read_text())
from src.live import book as B
f, come = U.frazione_cuscino()
assert abs(f - 2 * float(B.WEIGHT) * float(cfg["disaster_sl_pct"])) < 1e-12
usd, _ = U.cuscino_richiesto_usd(1000.0)
assert abs(usd - 1000.0 * f) < 1e-9
assert "SL" in come
def test_usde_convert_usa_la_stessa_formula():
C = _carica("usde_convert")
assert C.cuscino_richiesto_usd(4469.46) == U.cuscino_richiesto_usd(4469.46)
def test_quota_ripristino_lascia_margine_sul_cuscino():
f, _ = U.frazione_cuscino()
q0, q10 = U.quota_ripristino(0.0), U.quota_ripristino(0.10)
assert abs(q0 - (1 - f)) < 1e-12 # a margine zero e' il complemento del cuscino
assert q10 < q0 # col margine si vende UN PO' di piu'
# dopo la riconversione a q10 lo slack e' esattamente il 10% del cuscino
E = 4000.0
usdc_dopo = E * (1 - q10)
assert abs((usdc_dopo - E * f) - 0.10 * E * f) < 1e-9
# ------------------------------ i tre stati ------------------------------
def test_stati_ok_preavviso_scoperto():
assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == "OK" # slack 300 > 120
g = W.giudica(1300.0, 4000.0, 1200.0)
assert g["stato"] == "PREAVVISO" and abs(g["slack"] - 100.0) < 1e-9 and g["preavviso_usd"] == 120.0
assert W.giudica(1199.0, 4000.0, 1200.0)["stato"] == "SCOPERTO"
def test_non_vedo_e_blind_non_ok():
assert W.giudica(None, 4000.0, 1200.0)["stato"] == "BLIND"
assert W.giudica(1500.0, None, None)["stato"] == "BLIND"
# ------------------------------ transizione e trasporto ------------------------------
def test_ok_non_allerta_mai():
assert not W.transizione(None, "OK") and not W.transizione({"stato": "SCOPERTO"}, "OK")
def test_transizione_alla_prima_e_al_cambio_di_stato():
assert W.transizione(None, "PREAVVISO")
assert W.transizione({"stato": "OK", "allertato": None}, "PREAVVISO")
assert W.transizione({"stato": "PREAVVISO", "allertato": True}, "SCOPERTO")
assert not W.transizione({"stato": "PREAVVISO", "allertato": True}, "PREAVVISO")
def test_un_invio_fallito_non_consuma_la_transizione():
"""Debito §5.2: il marcatore 'gia' detto' e' l'esito dell'invio."""
assert W.transizione({"stato": "SCOPERTO", "allertato": False}, "SCOPERTO")
assert W.transizione({"stato": "SCOPERTO", "allertato": None}, "SCOPERTO")
# ------------------------------ guardie della riconversione ------------------------------
def test_disarmare_il_libro_disarma_la_riconversione():
ok, m = W.puo_riconvertire([], 0, False, 1.0, 0.99)
assert not ok and "execution_enabled" in m
def test_sotto_depeg_non_si_vende_al_buio():
ok, m = W.puo_riconvertire([], 0, True, 0.985, 0.99)
assert not ok and "depeg" in m
ok, m = W.puo_riconvertire([], 0, True, None, 0.99)
assert not ok and "leggibile" in m
def test_un_tentativo_ogni_sei_ore():
now = 1_789_000_000_000
prev = [{"ts": now - 2 * 3_600_000, "riconversione": {"tentata": True}}]
ok, m = W.puo_riconvertire(prev, now, True, 1.0, 0.99)
assert not ok and "non si insiste" in m
prev = [{"ts": now - 7 * 3_600_000, "riconversione": {"tentata": True}}]
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
# un record SENZA riconversione non conta come tentativo
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
chiamate = []
class R:
returncode, stdout, stderr = 0, " → piano valido\n ESITO: 10 USDE\n", ""
def finto_run(cmd, **kw):
chiamate.append(cmd)
return R()
monkeypatch.setattr(W.subprocess, "run", finto_run)
e = W.riconverti(0.67)
assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py")
assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
assert e["coda"][-1].startswith(" ESITO")
def test_il_monitor_e_registrato_in_monitor_health():
from src.live.monitor_health import MONITORS
spec = [m for m in MONITORS if m.name == "cuscino_watch"]
assert spec and spec[0].cadence_h == 1.0 and spec[0].open_labeled is False
def test_un_non_tentativo_non_consuma_il_budget():
"""Prezzo illeggibile, --secco, interruttore spento: `tentata=False` non blocca il giro dopo."""
now = 1_789_000_000_000
prev = [{"ts": now - 3_600_000, "riconversione": {"tentata": False, "motivo": "--secco"}}]
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
def test_dopo_il_ripristino_lo_stato_e_OK_non_preavviso():
"""Il margine di ripristino deve superare la soglia di preavviso, o ogni 🚨 e' seguito da un ⚠️
per costruzione (P14). Include il floor di 1 USDE e la tolleranza di quota di usde_convert."""
assert W.MARGINE_RIPRISTINO > W.PREAVVISO_FRAC
f, _ = U.frazione_cuscino()
E = 4450.0
q = U.quota_ripristino(W.MARGINE_RIPRISTINO) + 0.005 # usde_convert si ferma entro TOLL
usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno
assert W.giudica(usdc_dopo, E, E * f)["stato"] == "OK"
def test_cron_installato_e_dichiarato_fuori_dal_tondo_e_dopo_il_book():
"""Legge la crontab VIVA (come test_book_cadenza), non un commento nello .sh (P1)."""
import shutil
import subprocess
s = (ROOT / "scripts" / "cron_cuscino.sh").read_text()
assert "cuscino_watch.py --quiet" in s
if not shutil.which("crontab"):
return # SALTATO: distinto da 'assente'
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
if out.returncode != 0 or "PythagorasGoal" not in out.stdout:
return # altra macchina / altro utente
righe = [ln for ln in out.stdout.splitlines() if "cron_cuscino.sh" in ln and not ln.startswith("#")]
assert len(righe) == 1, "cron_cuscino.sh deve essere installato UNA volta"
minuto, ora = righe[0].split()[:2]
assert minuto == "53" and ora == "*" # orario, dopo il book (:47)
+126
View File
@@ -0,0 +1,126 @@
"""GATE PREVDAY-01 cablato nel monitor (issue #4): kill, veto d'integrita', data.
Prima del 10/09 il gate esisteva solo nel testo (RESULTS-0822 §54) e la revisione settimanale lo
ha letto come «senza data». Qui si blinda che: (a) le costanti sono quelle scritte il 23/08;
(b) il kill non e' leggibile sotto 180 giorni attivi e scatta solo sotto -0,50; (c) il veto
blocca sotto l'80% di barre ricostruibili e su una crescita MATERIALE delle divergenze — non su
una barra sola (P14); (d) la ricostruzione riproduce una serie generata con la stessa aritmetica
di `advance`; (e) la lente del kill e' GIORNALIERA."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sp = importlib.util.spec_from_file_location("paper_prevday", ROOT / "scripts" / "live" / "paper_prevday.py")
PP = importlib.util.module_from_spec(sp)
sp.loader.exec_module(PP)
def test_le_costanti_sono_quelle_del_gate_scritto_il_23_08():
assert PP.GATE_DATE == "2027-06-21"
assert PP.KILL_SHARPE == -0.50 and PP.KILL_MIN_ACTIVE_DAYS == 180
assert PP.MIN_RECON_FRAC == 0.80
# e CLAUDE.md §4 cita la stessa data (era assente fino al 10/09)
assert "2027-06-21" in (ROOT / "CLAUDE.md").read_text()
def _serie(n_giorni: int, mu_h: float, sd_h: float, seed: int = 0, pos: float = 0.3) -> pd.DataFrame:
rng = np.random.default_rng(seed)
n = n_giorni * 24
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
x = rng.normal(mu_h, sd_h, n)
return pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
net_real=x, pos_btc=pos, pos_eth=pos))
def test_kill_non_leggibile_sotto_180_giorni_attivi():
r = _serie(81, -0.001, 0.004)
sh, n = PP.sharpe_giornaliero(r)
assert n == 81 and sh < PP.KILL_SHARPE # Sharpe pessimo...
stato, motivo = PP.kill_check(sh, PP.giorni_attivi(r), pd.Timestamp("2026-09-10", tz="UTC"))
assert stato == "NON_MATURO" and "99" in motivo # ...ma il kill non e' leggibile
def test_kill_scatta_sopra_180_giorni_solo_sotto_la_soglia():
r_male = _serie(200, -0.001, 0.004)
sh, _ = PP.sharpe_giornaliero(r_male)
assert PP.kill_check(sh, PP.giorni_attivi(r_male), pd.Timestamp("2027-01-01", tz="UTC"))[0] == "KILL"
oggi = pd.Timestamp("2027-01-01", tz="UTC")
assert PP.kill_check(-0.49, 200, oggi)[0] == "SUPERATO" # la soglia e' STRETTA: -0,49 passa
assert PP.kill_check(-0.51, 200, oggi)[0] == "KILL"
assert PP.kill_check(float("nan"), 200, oggi)[0] == "NON_MISURABILE"
def test_i_giorni_flat_non_contano_come_attivi():
r = _serie(200, 0.0, 0.004)
r.loc[r.index[: 50 * 24], ["pos_btc", "pos_eth"]] = 0.0
assert PP.giorni_attivi(r) == 150
def test_la_lente_del_kill_e_giornaliera_non_oraria():
"""§54: sulla lente oraria lo stesso forward vale +2,04 contro +1,56. Con autocorrelazione
positiva intragiornaliera la lente oraria SOVRASTIMA: il kill usa la giornaliera."""
rng = np.random.default_rng(3)
n = 120 * 24
base = np.repeat(rng.normal(0.0004, 0.004, 120), 24) # shock comune al giorno
x = base + rng.normal(0, 0.0005, n)
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
r = pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
net_real=x, pos_btc=0.3, pos_eth=0.3))
sh_g, _ = PP.sharpe_giornaliero(r)
assert PP._sharpe_orario(r) > sh_g * 2 # la lente oraria gonfia; il kill non la usa
def test_veto_sotto_80_pct_ricostruibili():
ok, m = PP.veto_check(0.79, 0.5, 0.5, 10)
assert not ok and "79.0%" in m
assert PP.veto_check(0.80, 0.5, 0.5, 10)[0]
def test_veto_su_crescita_materiale_non_su_una_barra(monkeypatch):
"""Il 10/09 la serie reale aveva UNA barra divergente su 1943 (26/08 00:00, il giorno del fix
di advance()) e la prima stesura del veto la leggeva come 'crescita' (0,03/g contro 0,01/g)."""
assert PP.veto_check(0.999, 0.012, 0.032, 1)[0] # 1 barra: non e' crescita
ok, m = PP.veto_check(0.95, 0.10, 0.50, 12) # 12 recenti, tasso 5x: crescita
assert not ok and "crescita" in m
assert PP.veto_check(0.95, 0.30, 0.50, 12)[0] # tasso < 2x: no
def test_ricostruzione_riproduce_una_serie_generata_come_advance(monkeypatch):
"""Feed sintetico, target congelato deterministico (monkeypatch su prevday_target): la serie
registrata con l'aritmetica di advance si ricostruisce al 100%; una barra manomessa no."""
n = 24 * 10
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
rng = np.random.default_rng(7)
dfs = {}
for a, p0 in (("BTC", 60000.0), ("ETH", 2000.0)):
close = p0 * np.cumprod(1 + rng.normal(0, 0.003, n))
dfs[a] = pd.DataFrame(dict(timestamp=ts, datetime=pd.to_datetime(ts, unit="ms", utc=True), close=close))
tgt = {a: np.where((np.arange(n) // 24) % 2 == 0, 0.3, -0.3) for a in dfs}
monkeypatch.setattr(PP, "prevday_target",
lambda df: tgt["BTC"] if float(df["close"].iloc[0]) > 10000 else tgt["ETH"])
start = int(ts[23])
# registrazione con la stessa aritmetica di advance (ribilanciamento continuo)
pos = {a: float(tgt[a][23]) for a in dfs}
rows = []
for i in range(24, n):
net = 0.0
for a in dfs:
c = dfs[a]["close"].values
rr = c[i] / c[i - 1] - 1
net += PP.WEIGHT * (pos[a] * rr - PP.FEE_SIDE * abs(tgt[a][i] - pos[a]))
pos[a] = float(tgt[a][i])
rows.append(dict(ts=int(ts[i]), net_modeled=round(net, 6), pos_btc=pos["BTC"], pos_eth=pos["ETH"]))
r = pd.DataFrame(rows); r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
rc = PP.ricostruzione(r, dfs, start)
assert rc["ricostruibili"] == rc["n"] == n - 24 and rc["assenti_nel_feed"] == 0
r2 = r.copy(); r2.loc[r2.index[-1], "net_modeled"] += 0.01
rc2 = PP.ricostruzione(r2, dfs, start)
assert rc2["ricostruibili"] == rc2["n"] - 1 and rc2["div_recenti"] == 1
+61
View File
@@ -205,3 +205,64 @@ def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica" assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo" assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
assert q("2026-08-31", "NON_IDONEO") is False assert q("2026-08-31", "NON_IDONEO") is False
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
# Il 07/09 il lettore chiedeva limit=50, la sonda del 06/09 aveva fatto 54 ordini: 400 USDE di
# acquisti sono finiti nel «reward». Il lettore ora chiede il massimo del venue (1000) e, se la
# lista puo' essere incompleta — troncata al limite, o ultima lettura oltre la finestra di 24h
# che il gateway espone — dichiara «non leggibile» invece di sommare (P12).
class _ClientFinto:
def __init__(self, rows):
self.rows, self.limit_chiesto = rows, None
def trade_history(self, instrument, limit=20):
self.limit_chiesto = limit
return self.rows[:limit]
def _ordini(n, t0_ms, amt=100.0, passo_ms=15_000):
return [{"timestamp": t0_ms + i * passo_ms, "direction": "buy", "amount": amt} for i in range(n)]
def test_controllo_positivo_54_ordini_si_sommano_tutti():
"""Il caso del 06-07/09: 54 ordini in 24h. Con il vecchio limit=50 ne mancavano 4."""
prev, now = 1_788_698_101_000, 1_788_784_501_000 # 06/09 12:35Z -> 07/09 12:35Z
c = _ClientFinto(_ordini(54, prev + 30_000_000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert motivo is None and n == 54 and abs(tot - 5400.0) < 1e-9
assert c.limit_chiesto == W.TRADE_LIMIT >= 54
def test_lista_al_limite_del_venue_non_e_leggibile():
prev, now = 1_788_698_101_000, 1_788_784_501_000
c = _ClientFinto(_ordini(W.TRADE_LIMIT + 5, prev + 1000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert tot is None and n is None and "troncata" in motivo
# e il reward NON si attribuisce (P12)
an = W.analizza({"eq_usde": 1000.0}, 1400.0, tot)
assert an["reward_stimato"] is None and not an["reward_rilevato"]
def test_lettura_oltre_la_finestra_del_gateway_non_e_leggibile():
"""Giro saltato: 48h dall'ultima lettura. I trade fra 48h e 24h fa sono invisibili."""
prev = 1_788_698_101_000
now = prev + 48 * 3_600_000
c = _ClientFinto(_ordini(3, now - 3_600_000))
tot, n, motivo = W._trades_spot_da(c, prev, now)
assert tot is None and "finestra" in motivo
def test_la_cadenza_del_cron_sta_dentro_la_tolleranza():
"""24h + qualche secondo (il cron delle 12:35 di due giorni consecutivi) e' coperta."""
prev = 1_788_698_101_000
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + 2_000) is None
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + W.TOLL_FINESTRA_MS + 1) is not None
def test_i_trade_prima_della_lettura_precedente_non_contano():
prev, now = 1_788_698_101_000, 1_788_784_501_000
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9