Compare commits

5 Commits

Author SHA1 Message Date
Adriano Dal Pastro 95424f0755 docs: il README era fermo a 15 giorni PRIMA del reset — riscritto contro lo stato vero
Ultimo commit del README: 2026-06-04. Reset v2.0.0: 2026-06-19. Per 75 giorni la prima pagina
del repo ha pubblicato la libreria pre-reset (FADE/HONEST/PAIRS/TSMOM/SHAPE, PORT01-06) con
Sharpe 7,84/10,06 e CAGR ~79% come risultati correnti — cioe' esattamente i numeri che §2
elenca sotto "non citare", dalla libreria che il reset aveva dichiarato artefatto. Meta' dei
file citati non esiste piu' (strategies.yml, portfolios.yml, scripts/waste, scripts/portfolios,
src/live/multi_runner.py), e l'esecuzione descritta era su TESTNET, che e' la causa del reset.

- README riscritto (423 -> 152 righe): cosa gira adesso (TP01+SKH01 75/25, ~$2.050, cadenza
  oraria :47), i numeri nella lente di §2 (TWR +10,6%, non la crescita del conto), la riga che
  ordina il piano, il metodo e i suoi sei requisiti, il dato, la struttura VERIFICATA file per
  file, i comandi, i gate con le loro date, l'obiettivo con la sua onesta'.
- CLAUDE.md §0 e memoria 40: registrato il difetto e la lezione — un reset invalida anche i
  documenti che nessuno rilegge; l'inventario di cosa cita numeri morti va fatto il giorno del
  reset, non 75 giorni dopo per caso.
- Diario 02/09c: sezione col confronto riga per riga; coda dichiarata (l'inventario completo
  degli altri documenti pre-reset non e' stato fatto).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:53:42 +00:00
Adriano Dal Pastro de83909db9 un flag sconosciuto non e' l'azione di default: guardia su 18 script, indice USDE orario, pulizia
MISURATO oggi durante la revisione: `trades_db.py --help` non stampava l'uso, cadeva in sync()
e riscriveva meta.ultimo_sync. Nessuno dei 18 script di scripts/live/ usava argparse: un flag
sbagliato era il ramo else. Su journal.py avrebbe scritto pagina e riga di DB, su analista.py
avrebbe speso una chiamata al modello e mandato un Telegram.

- src/live/cli.valida: prima istruzione di ogni __main__, prima di connect()/sync/rete.
  --help -> 0 con l'uso; flag ignoto, valore mancante o posizionale -> 2 con l'elenco dei
  previsti (P4). NIENTE argparse: cambierebbe messaggi, codici d'uscita e --help di script
  che il cron gia' chiama.
- 18 script cablati (i 3 che scrivono + 14 + cc01), flag invariati.
- tests/test_cli_flag.py (30): elenco DERIVATO dalla cartella (P1), valida come prima
  istruzione, uso che documenta i flag, e i flag che il CRON usa davvero restano accettati
  (P15/P16); end-to-end su --help e flag ignoto con trades.db non toccato (M15).
  Verificato a mano: monitor_health --quiet, trades_db --sync --quiet, book_execute dry-run.

Debito §5.15, primo passo: balance_watch (orario) registra `usde_usdc` a ogni campione — None
con la ragione se illeggibile, mai 1,0. Il cablaggio nel bound quando la serie ha storia.

Pulizia dalla revisione: tests/helpers.carica_script al posto della 15a copia del loader
importlib (5 file del libro live); il fill di prova via upsert_fills invece di un INSERT che
lasciava verified NULL; asserzioni non ancorate al padding; movimenti_capitale accetta le
righe gia' lette (una SELECT invece di due ai due lati di una scrittura del cron).

Test 910 verdi (+52). Diario 2026-09-02c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:49:40 +00:00
Adriano Dal Pastro 1279ea605a revisione 02/09, seconda tornata: il classificatore dei movimenti era cieco ~23 ore al giorno
Il feed 1h certificato si ferma alle 00:00: per le letture successive `asof` dava la stessa
barra a t0 e t1, mercato "fermo" = 0, e qualunque calo >=10% del giorno sarebbe stato un
"movimento" scorporato come prelievo (giornale dal 25/08, report da oggi). Con feed assente
tutto era "ambiguo" e il report stampava e1/e0-1 sotto l'etichetta TWR.

- journal.movimenti_capitale: stato "mercato non misurabile" (feed fermo/assente/barra
  mancante) => ambiguo con la ragione; leva_tetto = min(frac x n x scala, LEVA_LORDA_MAX) (P1).
- journal.rendimento_twr: salto non classificabile => twr E trading None con motivo.
- journal.pnl_giorno CHIAMA rendimento_twr (prima rifaceva cum - certi: "entrambi la chiamano"
  era falso); la pagina stampa il TWR; analista qualifica il cumulato "di cui versati".
- trades_db --report: la classe di ogni salto con la sua misura (mercato max a leva piena).
- book_execute docstring: banda dello stop -33,5%/-26,5% (era invertita), "non costa ordini"
  -> micro-ordini di ri-taglia (22/48, C2), latenza <=1h solo con feed fresca, conteggi di
  r0823 al posto di "raddoppia" (2 contro 1 e' rotolante vs pavimento sulla riga 4h; la
  peggiore misurata e' 24h).
- test_book_cadenza: LTF_MIN importato da skyhook; tutte le righe attive della crontab (una
  sola); tre stati dello skip (col progetto ma senza cron_book => ROSSO); slot di release
  testato con venue_probe (il :07 come controllo positivo); parser regolare, passo > 0.
- r0823_sl_anchor: guardia sul :47 (era sul vecchio :07), prosa al passato; cron_chain.sh idem.
- CLAUDE.md: §5.7 frase invertita corretta, §5.14 limite vero (non l'artefatto della fixture),
  §2 ora della LETTURA (13:47Z), nuovo debito §5.15 (USDE fuori dal bound di mercato).
- diari: tempi "scritto" corretti coi commit; sezione "Seconda tornata".

Test +11 (858 verdi).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:33:13 +00:00
Adriano Dal Pastro 835e0c8666 revisione 02/09: il rotolante si CONTROLLA ogni ora (non "si ri-ancora"), TWR con limiti dichiarati e tre stati
Quattro segnalazioni della revisione sui commit di oggi, tutte verificate e riparate.

1. book_execute.py (+ CLAUDE.md §5.7, memoria 40): «il disaster-SL rotolante si ri-ancora ogni
   ora» era falso — ensure_disaster_sl lascia il bracket finche' lo stop e' entro il 5% e la
   taglia entro il 10%; il giro orario CONTROLLA, ri-ancora oltre la tolleranza (mark
   +5,263%/-4,762%). Lo stop siede fra -26,3% e -33,3% dal mark corrente.
2. journal.rendimento_twr: limite dichiarato (D5) — l'intervallo che contiene un movimento
   certo esce intero dal rendimento, il suo P&L di mercato va in `certi`; errore massimo meta'
   del movimento per costruzione (25/08: ~$0,5). Non si stima (P12). Segmenti a lunghezza zero
   non prodotti; nessun tempo a mercato -> 0,0 dichiarato numero.
3. base di equity zero: `twr` E `trading` None con motivo (il salto 0->X e' invisibile al
   classificatore, `trading` valeva l'intero conto); il report stampa n/d.
4. test_book_cadenza: la guardia estrae ogni prescrizione «ogni/every N unita'» e pretende 60
   minuti, con controllo positivo (M15); limite P13 dichiarato.

Test +5 (847 verdi). Diari 02/09 e 02/09b con la sezione "Revisione".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RziUCB336YPUUyDJ29x4Ke
2026-09-02 15:17:05 +00:00
Adriano Dal Pastro 861cc7fc27 debito 7: la cadenza del libro e' ORARIA — docstring riscritto, tre fonti tenute d'accordo da un test
Il docstring di book_execute.py prescriveva «ogni ~230 minuti» mentre il cron gira ogni ora
(47 * * * *). Era il docstring a sbagliare: sulla riga 4h BTC raddoppia gli scatti del
disaster-SL rotolante (r0823_sl_anchor.py), e chi avesse "corretto" il cron verso il
docstring avrebbe spostato il libro sulla riga peggiore.

- scripts/live/book_execute.py: CADENZA: ORARIA, la riga di crontab, la ragione (giro
  idempotente, latenza SKH01 <=1h, ri-ancoraggio orario) e il divieto esplicito con la data.
- tests/test_book_cadenza.py (10): deriva e confronta docstring, intestazione di
  cron_book.sh e crontab installata (crontab -l; SALTATO se illeggibile, non verde);
  parser a 5 campi solo per cadenze regolari; 60 < 230 < 240; minuto != :00.
- docs: CLAUDE.md §5.7 chiuso, §13 conteggio 842; memoria 40; diario 02/09b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RziUCB336YPUUyDJ29x4Ke
2026-09-02 14:38:53 +00:00
38 changed files with 1387 additions and 488 deletions
+62 -8
View File
@@ -32,6 +32,12 @@ Documento di fondazione: `docs/diary/2026-06-19-deribit-history.md`.
- Lo storico e' **ricostruito da Deribit mainnet e certificato**. Universo affidabile = **solo - Lo storico e' **ricostruito da Deribit mainnet e certificato**. Universo affidabile = **solo
BTC/ETH** (tutti i TF); gli alt sono esclusi (illiquidi/divergenti/non certificabili). BTC/ETH** (tutti i TF); gli alt sono esclusi (illiquidi/divergenti/non certificabili).
- Tutto il codice pre-reset e' archiviato in `Old/` (preservato in git, non cancellato). - Tutto il codice pre-reset e' archiviato in `Old/` (preservato in git, non cancellato).
- 🚨 **Il README e' rimasto fermo al 2026-06-04 fino al 2026-09-02**, cioe' *quindici giorni prima*
del reset: per 75 giorni la prima pagina del repo ha pubblicato la libreria pre-reset e i suoi
numeri (PORT06 Sharpe 7,84/10,06, CAGR ~79%) come risultati correnti, mentre il progetto li aveva
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
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 **~$635** - **L'esecuzione e' ARMATA e LIVE** su Deribit mainnet dal 2026-06-20. Capitale reale **~$635**
(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.
@@ -115,7 +121,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**) | **TWR +10,61%** spezzato sul versamento — +11,61% fino al 25/08, 0,89% dopo (02/09 14:02Z). Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso) | | performance del libro LIVE | «+243%» di equity (e' **96,3% un bonifico**) | **TWR +10,61%** spezzato sul versamento — +11,61% fino al 25/08, 0,89% dopo (lettura 02/09 13:47Z). Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso) |
📌 **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
@@ -282,9 +288,23 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
6. ⚠️ **TLT ha 13,5 anni di storia in meno** (parte 2016-02 invece del 2002) → **GTAA01 gira su 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 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**. ritorna 0 barre). ⇒ in-sample e hold-out di GTAA01 **non sono la stessa strategia**.
7. ⚠️ **Il docstring di `book_execute.py` prescrive "ogni ~230 minuti", il cron gira OGNI ORA.** 7. **RIPARATO (2026-09-02).** Il docstring di `book_execute.py` prescriveva "ogni ~230 minuti"
**Il docstring e' sbagliato e la configurazione che gira e' quella giusta** — chi lo "correggesse" mentre il cron gira **ogni ora** (`47 * * * *`): era il docstring a essere sbagliato, e chi
sposterebbe il libro sulla riga 4h, dove BTC **raddoppia** gli scatti del disaster-SL. avesse "corretto" il **CRON** verso il docstring avrebbe spostato il libro dalla riga 1h (rotolante:
BTC 0 scatti / ETH 1 in 7-8 anni) alla riga 4h (BTC 2, contro 1 del pavimento); la peggiore
misurata e' 24h (ETH 5, con 2 in 30 giorni) — `r0823_sl_anchor.py`. Ora il docstring dichiara
**CADENZA: ORARIA** con la ragione (giro idempotente: latenza SKH01 ≤1h; il rotolante e'
**controllato** ogni ora e ri-ancorato solo oltre la tolleranza di `ensure_disaster_sl`, mark
+5,263%/4,762% o taglia >10%, quindi lo stop siede fra **33,5% e 26,5%** dal mark corrente —
«si ri-ancora ogni ora» era una frase sbagliata, corretta in revisione il 02/09; e «girare piu'
fitto non costa ordini» pure: 22 dei 48 ordini live sono ri-taglie |Δ|≤$10, deadband in valuta
assoluta, C2) e il divieto esplicito. **`tests/test_book_cadenza.py` (10 test)** tiene
d'accordo le tre dichiarazioni — docstring, riga in `cron_book.sh`, **crontab installata**
(letta con `crontab -l`, **tutte** le righe attive: due righe sono due esecuzioni; tre stati —
illeggibile ⇒ SALTATO, leggibile senza questo progetto ⇒ SALTATO, leggibile col progetto ma senza
`cron_book.sh` attivo ⇒ **ROSSO**) — verifica 60 min < `skyhook.LTF_MIN` 230 < 240 (riga 4h),
minuto ≠ :00 **e fuori dallo slot di release** (`venue_probe.in_release_window`, col :07 come
controllo positivo). Diario `2026-09-02b-debito-7-cadenza-docstring.md`.
8. ⚠️ **Il Rulebook Deribit** (ADL, perdita socializzata, *emergency powers*, conti dormienti) **non 8. ⚠️ **Il Rulebook Deribit** (ADL, perdita socializzata, *emergency powers*, conti dormienti) **non
lo sorveglia nessuno**; `MAINT_GRACE_HOURS`=2 **presume** gli annunci invece di leggerli. lo sorveglia nessuno**; `MAINT_GRACE_HOURS`=2 **presume** gli annunci invece di leggerli.
*Ridotto in parte il 2026-08-25:* `src/live/venue_probe.py` interroga l'API **pubblica** *Ridotto in parte il 2026-08-25:* `src/live/venue_probe.py` interroga l'API **pubblica**
@@ -339,10 +359,43 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
elencati, e il delta $ grezzo etichettato «movimenti INCLUSI» — il `%` grezzo **non compare piu'**. elencati, e il delta $ grezzo etichettato «movimenti INCLUSI» — il `%` grezzo **non compare piu'**.
Test: 5 in `test_journal.py` (fra cui la riproduzione del +10,80% del diario 01/09, M23) + 2 in Test: 5 in `test_journal.py` (fra cui la riproduzione del +10,80% del diario 01/09, M23) + 2 in
`test_trades_report.py` sul testo stampato, con `connect()` deviato in tmp. Diario `test_trades_report.py` sul testo stampato, con `connect()` deviato in tmp. Diario
`2026-09-02-debito-14-report-twr.md`. ⚠️ **Limite ereditato e dichiarato (D5):** un +10% di `2026-09-02-debito-14-report-twr.md`. ⚠️ **Due limiti dichiarati dalla revisione del 02/09** (D5): (a) **l'intervallo che contiene un movimento certo esce INTERO dal rendimento** — il suo P&L di mercato finisce in `certi` e fuori da `trading`, con errore massimo pari a META' del movimento riconosciuto per costruzione del classificatore (il 25/08: ~$0,5 su $1.399); non si stima, si dichiara (P12); (b) **a base di equity zero `twr` E `trading` sono None**: il salto 0→X del primo versamento e' invisibile al classificatore e `trading` varrebbe l'intero conto. 🚨 **Seconda tornata (02/09, 15:30Z): il classificatore era CIECO ~23 ore al
trading fra due letture consecutive tocca la soglia del rilevatore e, a mercato fermo, verrebbe giorno.** Il feed 1h certificato si ferma alle 00:00 (rebuild 00:30): per le letture successive
classificato «movimento» — e' lo stesso limite del rilevatore live, non un difetto nuovo. `asof` dava la stessa barra a t0 e t1, mercato "fermo" = 0, e **qualunque calo ≥10% del giorno
sarebbe stato un «movimento»** — un crash o un depeg USDE stampato come prelievo, nel giornale
(dal 25/08) e nel report. Riprodotto su copia del DB. Ora un salto fuori dalla copertura del feed
(o con feed assente) e' **«mercato non misurabile»** ⇒ `ambiguo` con la ragione, e `twr`/`trading`
sono **None** con motivo (prima, a feed assente, `e1/e0-1` tornava sotto l'etichetta TWR). Il
«limite ereditato +10% a mercato fermo» scritto qui la mattina era un **artefatto della fixture
piatta**, non un limite del classificatore (con mercato misurato il trading non puo' superare
2·max_mkt): cancellato. E `pnl_giorno` ora **chiama** `rendimento_twr` (prima l'affermazione
«la funzione che entrambi chiamano» era falsa: il giornale rifaceva `cum certi`); la pagina
stampa il TWR accanto al cumulato e la testata Telegram dell'analista qualifica il cumulato
«di cui versati». `leva_tetto` del classificatore include la scala e il tetto di codice (P1).
15. ⚠️ **Il bound di mercato del classificatore dei movimenti guarda solo BTC/ETH, ma dal 26/08 il
31% dell'equity e' USDE** (trovato in revisione il 02/09). Un depeg dell'USDE a BTC/ETH fermi:
1,5% (indice 0,95) resta sotto soglia e nel P&L; una rottura piena (31%) supera 2× un mercato
"fermo" e diventerebbe un **«movimento» scorporato come prelievo**, nel giornale e nel report —
l'invenzione silenziosa che P12 vieta. Il bound corretto somma `quota_usde × |Δ indice usde_usdc|`,
ma l'indice era registrato **una volta al giorno** (`usde_watch`, 12:35Z). ◐ **Primo passo fatto
il 02/09:** `balance_watch` (orario, sola lettura) registra ora `usde_usdc` a ogni campione —
None con la ragione se l'indice non e' leggibile, mai 1,0. **Manca il cablaggio nel bound**, che
si fa quando la serie ha storia. Intanto: chi legge un «movimento» negativo senza bonifico in
uscita **lo verifichi contro `balance_watch.jsonl`** prima di crederci.
16.**RIPARATO (2026-09-02).** Nessuno dei **18** script di `scripts/live/` usava argparse: un
flag sconosciuto non era un errore, era **il ramo `else`**. Costo misurato lo stesso giorno
durante una revisione: `trades_db.py --help` non stampava l'uso, cadeva in `sync()` e riscriveva
`meta.ultimo_sync`; sugli altri due sarebbe stato peggio — `journal.py --help` avrebbe scritto
pagina e riga di DB, `analista.py --help` avrebbe speso una chiamata al modello e mandato un
Telegram. Ora `src/live/cli.valida` e' la **prima istruzione** di ogni `__main__`: `--help`
stampa l'uso ed esce **0**, un flag ignoto esce **2** con l'elenco di quelli previsti (P4).
Niente argparse: cambierebbe messaggi, codici d'uscita e `--help` di script che il cron gia'
chiama — qui si lascia intatto cio' che gira. **`tests/test_cli_flag.py` (30)**: l'elenco degli
script si **deriva dalla cartella** (uno nuovo senza guardia fa fallire il test), verifica che
`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`.
--- ---
@@ -557,10 +610,11 @@ uv run python scripts/analysis/certify_feed.py [--local] # certifica i f
uv run python scripts/portfolio/run_portfolio.py # report del portafoglio (ricerca) uv run python scripts/portfolio/run_portfolio.py # report del portafoglio (ricerca)
uv run python scripts/live/paper_portfolio.py # avanza il paper (forward-only) uv run python scripts/live/paper_portfolio.py # avanza il paper (forward-only)
uv run python scripts/live/trades_db.py --report # stato libro di bordo + P&L (TWR, non e1/e0) uv run python scripts/live/trades_db.py --report # stato libro di bordo + P&L (TWR, non e1/e0)
uv run python scripts/live/<script>.py --help # uso e flag (dal 02/09: un flag ignoto esce 2)
uv run python scripts/live/trades_db.py --reconcile # incrocio delle 3 fonti sui fill uv run python scripts/live/trades_db.py --reconcile # incrocio delle 3 fonti sui fill
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 pytest # test (832, tutti verdi al 02/09) uv run pytest # test (910, tutti verdi al 02/09)
``` ```
```python ```python
+117 -387
View File
@@ -1,423 +1,153 @@
# PythagorasGoal # PythagorasGoal
Sistema di riconoscimento pattern frattali e predizione per il trading di criptovalute (BTC, ETH), ispirato al framework teorico di Serleto & Malanga (*Pythagoras Trading Prediction*). Ricerca e esecuzione di strategie algoritmiche su BTC/ETH, con **un libro che gira con soldi veri**
su Deribit mainnet dal 20 giugno 2026.
## Obiettivo > 🚨 **v2.0.0 — RESET del 2026-06-19. Tutto ciò che questo README diceva prima è archiviato in
> `Old/` e non è fidato.** L'intera libreria di strategie "validata out-of-sample" (le famiglie
> FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06, gli Sharpe fra 6 e 10) era un **artefatto
> di uno storico contaminato**: print fantasma di un feed *testnet* più storico Binance/USDT.
> Ri-testate sul feed reale ricostruito da Deribit mainnet, **perdono ogni anno**. Documento di
> fondazione: `docs/diary/2026-06-19-deribit-history.md`.
>
> Questo README descrive il progetto **dopo** il reset. È stato riscritto il 2026-09-02, dopo
> essere rimasto fermo al 4 giugno — quindici giorni prima del reset — mentre pubblicava i numeri
> che il progetto aveva già dichiarato falsi.
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware. **L'autorità sui numeri e sulle decisioni è `CLAUDE.md`**, e il racconto completo sta in
`docs/memory/`. Questo file è l'ingresso, non la fonte.
## Risultati ---
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con ## Cos'è vivo, adesso
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie, | | |
emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione |---|---|
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione: | libro live | **TP01 + SKH01 a 75/25**, nettati in software su una sola posizione per asset (50/50 BTC/ETH) |
| venue | Deribit mainnet, perpetual lineari USDC. Esecuzione **armata** dal 2026-06-20 |
| capitale | ~$2.050 (USDC + USDE), non i €2.000 nominali di nessun paper trader |
| cadenza | **oraria**, minuto `:47` (`scripts/cron_book.sh`) |
| leva | lorda ~0,28x su un tetto di 1,00x. Il cap non ha mai morso: **il vincolo è il segnale** |
| protezione on-book | un solo disaster-SL rotolante al 30% sulla posizione netta |
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) | **Non** sono nel libro live: XS01, VRP01, GTAA01, XSR01. Vivono nel portafoglio di **ricerca**
|----------|-----------|-----------|---------------------| (paper, 5 sleeve), che è una serie diversa da quella che gira — quasi tutti i numeri di portafoglio
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% | del progetto sono su quella, non su questa.
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
| **SHAPE** | ML walk-forward su feature di *forma* del prezzo | SH01 (LogisticRegression, orizzonte 12 barre) | diversificatore, corr +0.08 col resto |
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20% ## I numeri, con la loro lente
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
config universale (niente cherry-picking).
### Portafoglio combinato (la vera leva anti-drawdown) Ogni numero va scritto con la lente e la banda che gli appartengono. La tabella completa
("non citare" ↔ "citare") è in `CLAUDE.md` §2; qui i tre che contano.
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico | grandezza | valore onesto |
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve: |---|---|
| rendimento del libro live | **TWR +10,6%** dall'armamento, spezzato sul versamento del 25/08: +11,61% prima, 0,9% dopo |
| crescita di trading | **+$50** in 71 giorni, su 30 round-trip chiusi e $0,87 di fee totali |
| Sharpe del portafoglio di ricerca | **1,95** [1,81 2,12] full, **1,54** [1,11 1,91] hold-out |
| Portafoglio | CAGR | Max DD | Sharpe | ⚠️ Il rendimento dell'equity **non** è la performance: il 96% della crescita del conto è un
|-------------|------|--------|--------| bonifico. Chi legge una percentuale su questo progetto deve sapere se è un TWR o un rapporto fra
| FADE (6 sleeve) | ~46% | 8% | 3.9 | due saldi — è stato un difetto reale, riparato il 2026-09-02.
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
| **PORT06 live** (17 sleeve, cap pairs 33%, leva 2×, config EXIT-16) | ~79% | **2.6%** | 7.8 (FULL) / 10.1 (OOS) |
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25: ## La riga che ordina tutto il resto
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
> Tutto resta da confermare nel paper trading live.
## Come funziona > La ricerca ha smesso di essere il vincolo il 2026-07-26, e **sei ondate successive lo hanno
> confermato invece che ribaltarlo**. I vincoli sono **il capitale che entra** e **il conto che non
> sparisce**.
### MR01 — Bollinger Fade (mean-reversion) Misurato: il miglior candidato nuovo vale **+0,046 €/giorno**; versare €500/mese invece di €250
porta la probabilità di arrivare al traguardo in 20 anni **dal 14% all'85%**. E €100/mese in più
equivalgono a **+4,07%/anno di drift**, cioè più di tutta la leva autorizzabile.
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi ## Metodo — cosa deve superare una strategia nuova
di prezzo **rientrano verso la media** più di quanto proseguano:
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close. Sei requisiti, nessuno negoziabile (`CLAUDE.md` §8, gate in `scripts/research/alt/altlib.py`):
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Take-profit** alla media mobile (il rientro atteso).
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`. 1. **ingresso eseguibile** — direzione e prezzo da dati fino a `close[i]`, mai l'estremo di una candela;
2. **backtest netto** dopo fee Deribit realistiche, più la leva;
3. **out-of-sample** held-out, robustezza su griglia, sweep fee;
4. **liquidità e plausibilità** — un edge su un book fermo o su wick fantasma non è un edge;
5. i **gate**: `marginal_vs_tp01` (Sharpe marginale, non assoluto), `study_family_honest` con
deflated-Sharpe ≥ 0,95, `day_boundary_robust`, `anchor_luck_band`, `weights_tilt_null`;
6. codice, test e diario.
### Le altre famiglie Il progetto ha **73 regole di prim'ordine** (`CLAUDE.md` §6) — sul dato, sul metodo, sui costi,
sulla produzione e sul piano — ognuna pagata almeno una volta. La più
ricorrente, cinque occorrenze: *un sorvegliante deve derivare il proprio bersaglio dal codice
sorvegliato, mai ridichiararlo — un controllo puntato su una configurazione diversa da quella che
gira passa sempre, e non sta controllando niente.*
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro; ## Il dato
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
rendimenti. Stessa logica di reversione, indicatori indipendenti.
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
### Perché lo squeeze breakout è stato abbandonato - **La verità è Deribit mainnet**, perché è dove si esegue. Binance è un audit indipendente, mai
un'ancora per "ripulire": è USDT, ~10 bps fuori, fino al 3% sotto depeg.
- Universo certificato: **solo BTC/ETH**, ogni timeframe. Gli alt sono esclusi.
- Lo storico si aggiorna **solo** con `rebuild_history.py` e si certifica **sempre** con
`certify_feed.py`. Il vecchio downloader è la causa del reset.
- La catena opzioni si **raccoglie** ogni ora (`collect_chain.py`, minuto `:25`): un'ora non
raccolta è persa per sempre.
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità ## Struttura
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
### Lezione metodologica
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
## Struttura progetto
``` ```
PythagorasGoal/ src/
├── src/ data/downloader.py load_data(asset, tf) sui parquet certificati
├── data/ # Download e gestione dati (Cerbero MCP + Binance) strategies/ trend_portfolio.py (TP01) · skyhook.py (SKH01) · base · indicators
├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity portfolio/ portfolio.py (N sleeve + weights_tilt_null) · sleeves.py · gtaa.py
│ ├── backtest/ # Motore di backtesting con fee e metriche backtest/harness.py backtest onesto, senza look-ahead
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi live/ book.py (esecutore netto) · deribit · livefeed · usde
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats venue_watch · venue_probe · venue_news · monitor_health · scale_watch
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr tradesdb · journal · analista · notifier · cli
│ ├── live/ # Paper trading live su Deribit testnet scripts/
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs) live/ book_execute · trades_db · journal · analista · balance_watch
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente paper_* (forward-monitor) · usde_watch · usde_convert · fee_watch
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral) research/ r<data>_*.py — un file per esperimento, harness in alt/altlib.py
│ │ ├── strategy_loader.py # Import dinamico classi Strategy analysis/ rebuild_history · certify_feed · audit_feed · multi_source_check
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh
│ │ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS docs/
│ │ └── telegram_notifier.py memory/ LA MEMORIA — 6 file, indicizzati in testa a CLAUDE.md
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live) research/ RESULTS-0822 (§1-73, un registro per filone) · BRIEF-0822 · SPEC-scale-key
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio diary/ una voce per esperimento (144)
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual journal/ libro di bordo, una voce al giorno, 4 livelli per provenienza
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report) tests/ 910, tutti verdi
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume Old/ archivio pre-reset — consultabile, non fidato
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
│ ├── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
├── strategies.yml # Config multi-strategy paper trader
├── data/
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
├── docs/
│ ├── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
├── docker-compose.yml
└── pyproject.toml
``` ```
## Strategie attive ## Comandi
Le strategie single-asset estendono `src.strategies.base.Strategy`
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
| Codice | Script | Famiglia | Descrizione |
|--------|--------|----------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
| **SH01** | `SH01_shape_ml.py` | SHAPE | LogisticRegression walk-forward su 17 feature di forma, orizzonte 12 barre (diversificatore) |
Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
salta i segnali col prezzo troppo esteso rispetto alla EMA200), un **loss-guard Hurst**
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
— dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
(`sl_confirm_atr=0.5`, 2026-06-04: lo stop scatta solo se la barra *chiude* oltre `sl ∓ 0.5·ATR14`
gli stop intrabar da wick erano falsi negativi, l'overshoot che buca lo stop è proprio il movimento
che la fade fada; a livello PORT06 porta l'OOS Sharpe da 8.82 a 10.06). Più un filtro `min_tp_frac`
che scarta i micro-scalp col take-profit entro il costo delle fee. Le tre protezioni sono
complementari: Hurst toglie il regime tossico, il trend-filter gli ingressi sovra-estesi, il
close-confirm i falsi stop. Portafogli pronti: `PORT01`
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
ROT01 (dominata da ROT02).
### Comandi utili
```bash ```bash
# Backtest di una strategia uv sync # dipendenze
uv run python scripts/strategies/MR01_bollinger_fade.py uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # storico da Deribit mainnet
uv run python scripts/strategies/PR01_pairs_reversion.py uv run python scripts/analysis/certify_feed.py # certifica i feed
uv run python scripts/live/trades_db.py --report # stato del libro + TWR
# Ricerca e validazione fee-aware out-of-sample uv run python scripts/live/journal.py # voce del giorno
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07 uv run python scripts/portfolio/run_portfolio.py # portafoglio di ricerca
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata uv run pytest # 910 test
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
# Gestione rischio, combinazione, report
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
# Validazione dei worker live (replay == backtest)
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
``` ```
## Paper Trading Live Ogni script di `scripts/live/` accetta `--help` e **rifiuta un flag che non conosce** (esce 2): fino
al 2026-09-02 un flag sbagliato eseguiva l'azione di default, e su tre script quell'azione scriveva.
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP, ## Gate aperti
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal` Un gate si decide **alla data**, coi criteri scritti **prima**. Elenco completo in `CLAUDE.md` §4.
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
### Avvio | gate | data | dove punta oggi |
|---|---|---|
| STATARB | 2026-09-27 | ritiro (Sharpe 1,61 sulla serie vera) |
| SCALA-01 — leva 1,00 → 1,25 | non prima del 2026-10-01 | chiave costruita e **inerte**; 9 condizioni, 2 fatte |
| XSR01 | 2026-10-23 | sotto la lente rendita il meccanismo vale ~0 |
| DVOLSPREAD | kill 2026-10-24 | serie 4,41 |
| GTAA01 tenere/bloccare | al book a $15k | contributo reale +0,10/+0,12 di Sharpe, non incassabile sotto la soglia |
```bash ## Obiettivo, e cosa lo rende difficile
# Locale
uv run python -m src.live.multi_runner
# Docker Il target dichiarato è **€50/giorno partendo da €1.000**. Non è raggiungibile a questo capitale:
docker compose up -d servono ~$313k (banda [$187k $1,14M]) o **€1.733/mese per dieci anni** per averlo al 90% di
``` probabilità. La leva non è la scorciatoia — quella difendibile vale quanto €100-150/mese di
versamento. La via è target-vol, capitale e tempo.
### Configurazione **Onestà prima di tutto**: nessun numero va creduto finché non è netto fee, out-of-sample, robusto
su griglia, e su dati certificati, liquidi ed eseguibili. Il resto di questo repo esiste per rendere
Le strategie attive sono definite in `strategies.yml`: quella frase verificabile invece che dichiarata.
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies: # strategie single-leg
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
pairs: # strategie a 2 gambe (market-neutral)
- name: PR01_pairs_reversion
a: ETH
b: BTC
tf: 1h
enabled: true
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
```
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
MR01_bollinger_fade__BTC__1h/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Paper Trading a Portafoglio
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
### Come funziona
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
### Schemi di ponderazione
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
### Portafoglio di default: PORT06
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Backtest canonico (dati al 2026-05-28): Sharpe 6.47 (FULL) / 8.82 (OOS), drawdown massimo 4.10% (FULL) / 1.30% (OOS), leva 2×; **con la config live attuale (EXIT-16 close-confirm): Sharpe 7.84 / 10.06, DD 2.60% / 1.15%**.
### Scope live
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
sono validati == backtest.
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
> (FULL 4.1%→2.4%; stop-loss fade 67% in numero, perdite totali 68%). Calcolato dalle sole close,
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
### Esecuzione reale (shadow, Deribit testnet)
Sette sleeve single-leg — le **6 fade** (MR01/MR02/MR07 × BTC/ETH) e **DIP01** (dal 2026-06-04) —
eseguono ordini **reali su Deribit testnet** accanto al fill simulato (*shadow*: il sim resta la
verità che guida le decisioni; il reale misura la fattibilità). Punti chiave:
- **Strumenti lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`): payoff lineare = matematica del
backtest; fee e PnL in USDC. Quantizzazione `Decimal` di amount (step) e prezzi (tick).
- **Take-profit reale = limit reduce-only AL livello** (v1.0.7): piazzato all'apertura, copre la
sola quota del worker (gli strumenti sono condivisi fra worker e nettati per conto); alla
chiusura il worker cancella il resting, riconcilia i fill dal trade history per `order_id` e
chiude a market solo il residuo. Fix della divergenza misurata: il market-on-poll usciva
+235 bps oltre il livello TP. Fill da resting = fee maker (~0%).
- **Stop-loss close-confirm** (v1.1.0): uscita al close che sfonda il livello → market
reduce-only al poll (nessun ordine stop sul book, per scelta: i trigger Deribit generano un
nuovo order_id allo scatto, non verificabile, e i wick non devono stoppare).
- **Verifica sul trade** (order_id in `get_trade_history`), fee reali dai `trades[]`, ledger
reale parallelo persistito (`real_capital`), eventi `REAL_OPEN`/`REAL_TP_RESTING`/`REAL_CLOSE`
nel log + alert Telegram (`REAL_EXEC_LIVE`, `REAL_OPEN_FAIL`).
- Config in `portfolios.yml``overrides.execution {enabled, sleeves, instruments}`.
**Pairs/rotation/TSMOM/shape restano simulati**: i pairs richiedono un executor a 2 gambe
(leg-risk), i multi-asset un rebalance-to-target; roadmap nel diario.
### Versione & deploy
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
```bash
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
./scripts/deploy.sh minor # 1.0.x → 1.1.0
```
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
### Avvio del paper trader a portafoglio
```bash
# Backtest del portafoglio di default (PORT06)
uv run python scripts/portfolios/PORT06_master_shape.py
# Paper trading live a portafoglio
uv run python -m src.portfolio.runner
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
uv run python scripts/portfolios/hourly_report.py
# Smoke test del data layer Cerbero v2
uv run python scripts/analysis/smoke_portfolio.py
```
## Setup
```bash
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
uv sync
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Backtest strategia attiva
uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
| Asset | Timeframe | Copertura |
|-------|-----------|-----------|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
Formato: Apache Parquet (in `data/raw/`, gitignored).
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
### Discovery & validazione strumenti
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
timeframe + data d'inizio).
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
```bash
uv run python -m src.data.instruments
```
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
## Riferimenti
- Serleto, L. & Malanga, C. — *Pythagoras Trading Prediction* (2024)
- Serleto, L. & Malanga, C. — *Libro dei Frattali* (2024)
## Licenza
Uso privato. Non destinato alla distribuzione.
+25 -2
View File
@@ -1,7 +1,6 @@
# 2026-09-02 — Debito 14: il report stampa il TWR, non il bonifico # 2026-09-02 — Debito 14: il report stampa il TWR, non il bonifico
*Scritto il 2026-09-02 fra le 14:16Z e le 14:30Z. Numeri dalla serie `equity` di `trades.db` *Scritto il 2026-09-02 fra le 14:16Z e le 14:22Z (commit `2f701b8`, 14:22:50Z; la prima stesura diceva «fino alle 14:30Z» senza averlo letto — corretto in revisione). Numeri dalla lettura di equity delle 13:47:01Z (1.678 letture) e dal report rilanciato dopo la riparazione.*
(1.678 letture) e dal report rilanciato dopo la riparazione.*
## Cosa c'era ## Cosa c'era
@@ -51,3 +50,27 @@ nel test, nel CLAUDE.md e qui.
dollari e la sua pagina ha una forma testata. Aggiungerlo è una riga, ma non era il debito. dollari e la sua pagina ha una forma testata. Aggiungerlo è una riga, ma non era il debito.
- Non ho scomposto lo scarto (~$25) fra round-trip + non realizzato e il trading al netto: era già - Non ho scomposto lo scarto (~$25) fra round-trip + non realizzato e il trading al netto: era già
fuori scope il 01/09 e lo resta. fuori scope il 01/09 e lo resta.
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8` 15:17:05Z) — tre cose trovate, tutte riparate
| trovato | vero? | riparazione |
|---|---|---|
| l'intervallo che contiene un movimento certo esce intero dal rendimento: il suo P&L di mercato finisce in `certi` e fuori da `trading`, e nessun documento lo diceva | sì, per costruzione. Errore massimo = metà del movimento riconosciuto (margine del classificatore). Il 25/08: ~$263 lordi a ±0,2% → ~$0,5 | dichiarato nel docstring, nel report, in CLAUDE.md §5.14 e in memoria 40. Non si stima (P12) |
| a base di equity zero `trading` era un numero mentre `twr` era None, e sbagliato: il salto 0→X è invisibile al classificatore, `trading` valeva l'intero conto sotto l'etichetta "al netto dei versamenti" | sì. Non raggiungibile dal cron (non scrive mai 0), ma raggiungibile da un backfill | tre stati veri: `twr` e `trading` entrambi None con motivo; il report stampa n/d. Test in `test_journal.py` e `test_trades_report.py` |
| movimento nel primo intervallo o due consecutivi → segmento `+0.00% [x -> x]` | sì, cosmetico | i segmenti a lunghezza zero non si producono; nessun tempo a mercato → TWR 0,0 dichiarato numero. Test |
Il numero pubblicato non cambia: +10,61% alle 14:02Z, +10,56% alle 14:47Z per la marcatura.
## Seconda tornata (15:30Z) — la piu' importante di tutte
| trovato | vero? | riparazione |
|---|---|---|
| il classificatore dei movimenti era **cieco ~23 ore al giorno**: il feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, qualunque calo ≥10% del giorno diventava «movimento». Riprodotto dalla revisione su copia del DB: 12% appeso alle 14:47 → `certi` 1.399 → 1.153, TWR invariato invece di 2,71% | si'. Verificato: ultima barra 00:00, adesso 15:26 | nuovo stato **«mercato non misurabile»** (feed fermo, assente, o barra mancante) ⇒ `ambiguo` con la ragione; `twr` e `trading` None con motivo |
| con `data/raw` assente (host ripristinato prima del rebuild) tutto era «ambiguo», `certi` 0, e il report stampava **+243% sotto l'etichetta TWR** | si' | stesso stato: n/d, mai il grezzo |
| «`rendimento_twr` e' la funzione che entrambi chiamano» era **falso**: `pnl_giorno` rifaceva `cum certi`, e la testata Telegram mandava il cumulato grezzo | si' | `pnl_giorno` chiama `rendimento_twr`; la pagina stampa il TWR accanto al cumulato; la testata dice «di cui versati → trading» |
| `leva_tetto` del classificatore ridichiarava `frac × n` ignorando `book_scale_k` e `LEVA_LORDA_MAX` (P1) | si' | `min(frac × n × scala, LEVA_LORDA_MAX)`; fallback = tetto di codice |
| il «limite ereditato: +10% di trading a mercato fermo» scritto la mattina era un **artefatto della fixture piatta** (a mercato misurato il trading non supera 2·max_mkt) | si' | cancellato ovunque; il limite vero e' quello sopra |
| il bound di mercato guarda solo BTC/ETH, ma il 31% dell'equity e' USDE: un depeg pieno sarebbe un «prelievo» | si' | **non riparato**: l'indice USDE e' registrato una volta al giorno. Debito **§5.15** |
| l'ora del report scritta in §2 (14:02Z) era l'ora di esecuzione, non quella della lettura (13:47:01Z); i tempi «scritto» dei diari erano posteriori ai commit | si' | corretti coi tempi veri |
Il numero pubblicato: TWR +10,61% alla lettura delle 13:47Z; +10,56% alle 14:47Z.
@@ -0,0 +1,67 @@
# 2026-09-02 — Debito 7: il docstring diceva 230 minuti, il cron gira ogni ora
*Scritto il 2026-09-02 fra le 14:36Z e le 14:38Z (commit `861cc7f`, 14:38:53Z; la prima stesura diceva «alle 14:45Z» senza averlo letto — corretto in revisione). Cadenza misurata dal log (`r0823_sl_anchor.py`: 1.443 giri in
1.442 ore) e dalla crontab installata, letta dal test.*
## Il difetto
| fonte | cosa diceva | dal |
|---|---|---|
| `scripts/live/book_execute.py`, docstring | «va lanciato ogni ~230 minuti» | 20/06 |
| `scripts/cron_book.sh`, intestazione | cadenza ORARIA, `47 * * * *` | 25/08 (prima `:07`, sempre orario) |
| crontab della VPS | `47 * * * *` | 25/08 |
Era il **docstring** a sbagliare. E non era una svista innocua: sulla riga 4h (la più vicina ai 230
minuti) BTC **raddoppia** gli scatti del disaster-SL rotolante (2 contro 1; a 24h ETH arriva a 5, con
2 in 30 giorni). Il rischio era un lettore diligente che "allineasse" il cron al docstring.
## La riparazione
1. **Docstring riscritto**: `CADENZA: ORARIA`, la riga di crontab citata, la ragione (giro
idempotente → girare più fitto della griglia non costa ordini; latenza ≤1h sugli ingressi/uscite
software di SKH01; ri-ancoraggio orario del rotolante) e il divieto esplicito, con la data.
2. **`tests/test_book_cadenza.py`, 10 test.** P1: non ridichiara «60 minuti», **deriva** le tre
fonti e le confronta fra loro:
- il docstring non contiene più «ogni ~230» e dichiara ORARIA;
- la riga di crontab citata nel docstring è quella dichiarata in `cron_book.sh`;
- la riga dichiarata è oraria (parser a 5 campi, solo casi regolari, rifiuta il resto),
sotto i 230 della griglia e lontana dai 240 della riga peggiore, e fuori dal minuto `:00`;
- **la crontab installata** (`crontab -l`) coincide con la dichiarata. Dove la crontab non è
leggibile il test è **saltato**, non verde (P5). Sulla VPS oggi gira e passa.
## Numeri
| | |
|---|---|
| test nuovi | 10, tutti verdi; suite dei moduli che toccano `book_execute`: 231 verdi |
| cadenza dichiarata / installata | 60 min / 60 min |
| griglia SKH01 | 230 min (la riga da non usare) |
| riga peggiore misurata | 240 min (BTC 2 scatti contro 1) |
## Cosa NON ho fatto
- Non ho toccato il cron né `cron_book.sh`: la configurazione che gira era quella giusta.
- Non ho aggiunto un sorvegliante a runtime sulla cadenza (contare i giri per ora nel log): il
giornale già stampa «giri mancanti» ogni giorno, e il test copre la deriva di configurazione.
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8`) — due cose trovate, tutte riparate
| trovato | vero? | riparazione |
|---|---|---|
| il docstring nuovo diceva «il disaster-SL rotolante si ri-ancora ogni ora»: falso. `ensure_disaster_sl` lascia il bracket com'è finché lo stop voluto è entro il 5% da quello piazzato e la taglia entro il 10%; il giro orario **controlla**, ri-ancora oltre la tolleranza (mark +5,263% / 4,762%). I «BTC 0 / ETH 1» di `r0823` sono misurati **con** questa isteresi | sì, verificato in `execution.py:233-235`. Propagato anche a CLAUDE.md §5.7 e memoria 40 | riscritto in tutti e tre i posti: «controllato ogni ora, ri-ancorato solo oltre la tolleranza; lo stop siede fra 26,3% e 33,3% dal mark corrente». Frase sbagliata su un meccanismo di sicurezza vivo: era la segnalazione più importante |
| la guardia del test cercava due letterali («ogni ~230»): «ogni quattro ore» sarebbe passato | sì | la guardia estrae **ogni** prescrizione «ogni/every N unità» e pretende 60 minuti; controllo positivo (M15) su cinque frasi. Limite dichiarato (P13): una prosa che prescrive senza «ogni» passa |
## Seconda tornata (15:30Z)
| trovato | vero? | riparazione |
|---|---|---|
| la frase nuova di CLAUDE.md §5.7 era **invertita** («chi lo avesse corretto nel verso del cron»): correggere il docstring verso il cron e' proprio la riparazione; il pericolo e' correggere il CRON verso il docstring | si' | riscritta nel verso giusto |
| la banda dello stop era sbagliata: con isteresi 5% sullo stop (0,70·mark) lo stop siede a 0,6650,735 × mark, cioe' **33,5% / 26,5%**, non 26,3/33,3 | si' | corretta in docstring, CLAUDE.md, memoria |
| «BTC raddoppia gli scatti» leggeva male `r0823`: il «2 contro 1» e' rotolante contro pavimento **dentro la riga 4h**; a 1h il rotolante ne fa 0, e la riga peggiore misurata e' **24h** (ETH 5) | si' (la revisione ha eseguito `r0823.simula`) | riscritto ovunque coi conteggi; nel test `RIGA_4H_MIN`, non «peggiore»; `LTF_MIN` importato da `skyhook` (P1) |
| «girare piu' fitto non costa ordini» era falso: 22 dei 48 ordini live sono ri-taglie |Δ|≤$10 a segnale invariato; il deadband e' in valuta assoluta (C2) | si' (contati) | riscritto: «costa solo micro-ordini di ri-taglia», con la regola C2 |
| «latenza ≤1h» senza condizione: vale solo con la feed 5m fresca; `fresh_5m` ripiega in silenzio sul giornaliero e `skh_feed_max_age_min` allerta e basta | si' | condizione scritta |
| `_cron_installato` leggeva la **prima** riga: una seconda riga attiva sotto (`17 * * * *`, dentro lo slot di release) era invisibile | si' (simulato) | si contano **tutte** le righe attive, e devono essere una |
| lo skip collassava tre stati: crontab illeggibile, leggibile senza il progetto, leggibile col progetto ma senza `cron_book.sh` attivo (= libro non schedulato) | si' | tre stati: SALTATO, SALTATO, **ROSSO** |
| solo il vincolo 1 del `:47` era testato (minuto ≠ 00); il vincolo 2 (fuori dallo slot di release del martedi') no | si' | test con `venue_probe.in_release_window`, il :07 come controllo positivo |
| `_cron_dichiarato` accettava solo `N * * * *` e falliva come «assente» su `*/30`; `cadenza_minuti` accettava `*/45`, `0 */5`, `*/0` | si' | regex a 5 campi qualunque, regolarita' e passo > 0 imposti, parametrizzati |
| `r0823_sl_anchor.py` si rifiutava di girare nella finestra del **vecchio** `:07` e stampava a runtime che il docstring «prescrive ~230 min»; `cron_chain.sh` giustificava il :25 «fuori dal :07» | si' | guardia sul :47, prosa al passato, commento aggiornato |
+103
View File
@@ -0,0 +1,103 @@
# 2026-09-02 — Un flag sconosciuto non è l'azione di default
*Scritto fra le 15:45Z e le 16:05Z. Il difetto è stato misurato, non ipotizzato: la revisione del
codice di questa sessione ha eseguito `trades_db.py --help` come sonda di tempi e lo ha visto
sincronizzare il DB.*
## 1. Il difetto
Nessuno dei 18 script di `scripts/live/` usava argparse. Leggevano gli argomenti così:
```python
if "--reconcile" in args: ...
elif "--report" in args: ...
else: sync() # <- qui finiva QUALUNQUE cosa non riconosciuta
```
Un flag sbagliato non era un errore: era il ramo finale. Costo osservato e costi possibili:
| script | `--help` faceva | gravità |
|---|---|---|
| `trades_db.py` | `sync()`: riscriveva `meta.ultimo_sync` | osservato oggi, danno nullo |
| `journal.py` | scriveva la pagina di oggi e una riga nel DB | non osservato, ma scrive |
| `analista.py` | spendeva una chiamata al modello e mandava un Telegram | non osservato, ma spende |
## 2. La riparazione, e perché non è argparse
`src/live/cli.valida(nome, uso, flag=..., con_valore=...)`, chiamata come **prima istruzione** di
ogni blocco `__main__` — prima di `connect()`, prima del sync, prima della rete.
- `--help` / `-h` → stampa l'uso, esce **0**;
- flag non dichiarato, o valore mancante, o posizionale → stderr con **cosa** e **cosa si poteva**
(P4), esce **2** (errore d'uso, distinto dall'1 con cui gli script segnalano un esito negativo);
- tutto il resto invariato.
**Argparse no.** Cambierebbe messaggi d'errore, codici d'uscita e il comportamento di `--help` su
18 script che il cron già invoca: romperebbe ciò che gira per riparare ciò che non gira mai. Qui
serviva l'opposto — lasciare intatti i flag esistenti e rifiutare solo l'ignoto.
## 3. Il test guarda entrambe le metà del contratto
`tests/test_cli_flag.py`, 30 test:
- **l'elenco degli script si deriva dalla cartella** (P1): uno script nuovo che legge `sys.argv` a
mano e non chiama `valida` fa fallire il test, non passa inosservato perché nessuno ha aggiornato
una lista;
- `valida` dev'essere la **prima istruzione** di `__main__` (uscire dopo l'effetto non è uscire);
- l'uso deve **nominare** i flag che lo script accetta;
- **i flag che il cron usa davvero devono restare accettati** (P15/P16, letti dai `cron_*.sh`): una
guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto;
- end-to-end sui tre script che scrivono: `--help` esce 0 e **non tocca `trades.db`**; un flag
ignoto esce 2 e non lo tocca (M15: il caso che ha causato il difetto, non un caso vicino).
Verificato a mano anche il contrario: `monitor_health.py --quiet`, `trades_db.py --sync --quiet`
e `book_execute.py` in dry-run escono 0 come prima.
## 4. Il resto della sessione
**Indice USDE orario** (debito §5.15, primo passo). Il classificatore dei movimenti di capitale
confronta due letture di equity a un'ora di distanza e il suo bound guarda solo BTC/ETH, mentre il
31% dell'equity è USDE: un depeg sarebbe scorporato come un prelievo. `usde_watch` registra
l'indice una volta al giorno — troppo rado. Ora `balance_watch` (orario, sola lettura) lo registra
a ogni campione, `None` con la ragione se non leggibile, mai 1,0. Il cablaggio nel bound viene
quando la serie ha storia: prima serve il dato, poi la regola.
**Pulizia lasciata dalla revisione.** `tests/helpers.carica_script` sostituisce la dodicesima copia
del caricatore `importlib` (15 in giro, con nomi di modulo diversi per lo stesso file e differenze
silenziose su `sys.modules`); il fill di prova si scrive con `upsert_fills` invece che con un
`INSERT` a mano che lasciava `verified` NULL, una forma che la produzione non produce; le
asserzioni sul testo del report non sono più ancorate al padding; `movimenti_capitale` accetta le
righe già lette, così il report non fa due SELECT sulla stessa tabella a un'ora del cron — due
letture ai due lati di una scrittura descriverebbero due istanti diversi.
## 5. Il README era fermo a quindici giorni prima del reset
Chiesto un aggiornamento della documentazione, ho aperto il README e ho trovato un documento del
**2026-06-04**. Il reset v2.0.0 è del **19 giugno**. Per 75 giorni la prima pagina del repository ha
descritto la libreria pre-reset come se fosse il presente:
| cosa diceva | stato vero |
|---|---|
| famiglie FADE / HONEST / PAIRS / TSMOM / SHAPE, strategie MR01…SH01 | archiviate in `Old/`, artefatto di feed contaminato |
| PORT06: Sharpe **7,84 / 10,06**, DD 2,60%, CAGR ~79% | numeri che `CLAUDE.md` §2 elenca sotto «non citare» |
| paper trader su `strategies.yml`, portafogli in `portfolios.yml` | **file inesistenti** |
| `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py` | **inesistenti** |
| esecuzione shadow su Deribit **testnet** | il testnet è *la causa del reset*; oggi si esegue su mainnet con soldi veri |
Riscritto contro lo stato vero: cosa gira adesso, i numeri nella lente di §2 (TWR +10,6%, non la
crescita del conto), il metodo e i sei requisiti, la struttura verificata file per file, i gate con
le loro date, l'obiettivo con la sua onestà. Ogni percorso citato è stato controllato: esiste.
Da 423 righe a 152.
**La lezione che ho registrato non è «aggiornare il README».** È che un reset invalida anche i
documenti che nessuno rilegge, e l'inventario di cosa cita numeri morti va fatto il giorno del
reset — non 75 giorni dopo, per caso, mentre si fa altro.
## 6. Cosa NON ho fatto
- I loader `importlib` dei test sulla **ricerca** (moduli in `scripts/research/`) restano come
sono: sono a livello di modulo, con radici diverse, e toccarli non ripara niente.
- Il bound USDE nel classificatore: manca la storia, non il codice.
- Non ho cercato altri documenti fermi al pre-reset fuori da `README.md` e `docs/`: l'inventario
completo (ogni file che cita un numero morto) resta da fare, ed è la vera coda di questa scoperta.
+60 -3
View File
@@ -703,9 +703,16 @@ e i vincoli di deploy (PRIIPs/UCITS/broker).
movimento certo e moltiplica i segmenti (gli ambigui non spezzano: restano nel rendimento, movimento certo e moltiplica i segmenti (gli ambigui non spezzano: restano nel rendimento,
dichiarati accanto — P12). Il report stampa TWR, segmenti con le date, movimenti elencati, dichiarati accanto — P12). Il report stampa TWR, segmenti con le date, movimenti elencati,
trading al netto, e il delta $ grezzo solo etichettato «movimenti INCLUSI». Verifica M23: la trading al netto, e il delta $ grezzo solo etichettato «movimenti INCLUSI». Verifica M23: la
macchina riproduce il +10,80% del diario 01/09 sui suoi quattro punti. **Limite ereditato:** un macchina riproduce il +10,80% del diario 01/09 sui suoi quattro punti. **Due limiti trovati in revisione (02/09):** l'intervallo che contiene un movimento certo esce INTERO dal rendimento (il suo P&L di mercato va in `certi`; errore massimo META' del movimento, per costruzione; il 25/08 ~$0,5) e a base di equity zero `twr` e `trading` sono entrambi None, perche' il salto 0→X del primo versamento e' invisibile al classificatore. I segmenti a lunghezza zero (movimento nel primo intervallo, o due consecutivi) non si stampano. **Seconda tornata di revisione, stesso giorno:** il classificatore era **cieco ~23 ore al giorno** — il
+10% di trading fra due letture orarie consecutive, a mercato fermo, sarebbe classificato feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, e
movimento — e' il limite dichiarato del rilevatore live (D5), non uno nuovo. qualunque calo ≥10% del giorno sarebbe stato un movimento (un crash stampato come prelievo, dal
25/08); con feed assente tutto era «ambiguo» e il report stampava `e1/e0-1` sotto l'etichetta TWR.
Ora «mercato non misurabile» è uno stato: `ambiguo` con la ragione, `twr`/`trading` None con motivo.
Il «limite +10% a mercato fermo» scritto la mattina era un artefatto della fixture piatta, cancellato.
`pnl_giorno` chiama `rendimento_twr` (la frase «entrambi la chiamano» è vera solo da questa tornata),
la pagina stampa il TWR, la testata Telegram qualifica il cumulato «di cui versati», e il bound di
leva include la scala e il tetto di codice. Resta aperto (§5.15): il bound guarda solo BTC/ETH, e il
31% dell'equity è USDE — un depeg pieno sarebbe scorporato come prelievo.
**Le tre fonti si INCROCIANO e non si sovrascrivono:** log (ora vera) x jsonl (i fill) x venue **Le tre fonti si INCROCIANO e non si sovrascrivono:** log (ora vera) x jsonl (i fill) x venue
(autorevole ma **TRONCA** — 1 trade su BTC, 0 su ETH). `reconcile()` riporta le divergenze e (autorevole ma **TRONCA** — 1 trade su BTC, 0 su ETH). `reconcile()` riporta le divergenze e
**non ripara niente da solo**: fra due fonti che non concordano, una riparazione silenziosa e' **non ripara niente da solo**: fra due fonti che non concordano, una riparazione silenziosa e'
@@ -915,6 +922,56 @@ Sulle 4 finestre osservate 3 sono rientrate entro l'ora → il `:47` ne avrebbe
**Se al prossimo martedì il `:47` becca comunque la manutenzione, la previsione è sbagliata** e lo **Se al prossimo martedì il `:47` becca comunque la manutenzione, la previsione è sbagliata** e lo
slot non è quello descritto in `venue_probe.RELEASE_*`: rileggerlo prima di spostare ancora. slot non è quello descritto in `venue_probe.RELEASE_*`: rileggerlo prima di spostare ancora.
### La cadenza è ORARIA, e ora lo dicono tutte e tre le fonti (debito §5.7, chiuso il 2026-09-02)
Il docstring di `book_execute.py` prescriveva «ogni ~230 minuti» (la griglia di SKH01) mentre il cron
gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che l'accumulo di scatti del disaster-SL
rotolante è funzione della **cadenza del cron** (1h: BTC 0 / ETH 1 in 7-8 anni · 4h: BTC 2 · 24h: ETH 5
con 2 in 30 giorni), quindi chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro
dalla riga 1h (rotolante: BTC 0 / ETH 1) alla riga 4h (BTC 2 contro 1 del pavimento; la peggiore misurata è 24h, ETH 5). La riparazione è il docstring stesso — cadenza ORARIA, la ragione (giro
idempotente: girare più fitto della griglia non costa ordini, e compra latenza ≤1h per gli
ingressi/uscite software di SKH01 e un **controllo** orario del rotolante — che si ri-ancora solo oltre la tolleranza di `ensure_disaster_sl`, mark +5,263%/4,762% o taglia >10%, quindi lo stop siede fra 33,5% e 26,5% dal mark corrente: la prima stesura diceva «si ri-ancora ogni ora» ed era sbagliata, corretta in revisione lo stesso giorno) e il divieto scritto —
più `tests/test_book_cadenza.py`, che **deriva** (P1) le tre dichiarazioni e le confronta: la riga
citata nel docstring, quella dichiarata nell'intestazione di `cron_book.sh` (`47 * * * *`) e la
crontab **installata** (`crontab -l`; se non è leggibile il test è SALTATO, non verde — P5). Il
parser accetta solo espressioni regolari e rifiuta il resto. Vincolo del minuto tondo verificato
(≠ `:00`). La regola generale è la stessa del `:47`: **una cadenza si difende nella sua ragione, e
un commento che dichiara una cadenza diversa da quella che gira si paga quando qualcuno lo "corregge"
nel verso sbagliato.**
### Il README ha pubblicato per 75 giorni la libreria che il progetto aveva dichiarato falsa
Ultimo commit del README: **2026-06-04**. Reset v2.0.0: **2026-06-19**. In mezzo, quindici giorni; dopo,
75 in cui la prima pagina del repository descriveva FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06,
il paper trader su `strategies.yml` e l'esecuzione shadow su testnet — con Sharpe 7,84/10,06 e CAGR ~79%
presentati come risultati correnti. Metà dei file citati non esisteva più (`strategies.yml`,
`portfolios.yml`, `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py`); i numeri erano
esattamente quelli che `CLAUDE.md` §2 elenca sotto «non citare», e provenivano dalla libreria che il
reset aveva dichiarato artefatto di un feed contaminato.
Nessun danno sui soldi — il README non decide niente. Il danno è di **citazione**, ed è la forma peggiore:
un lettore esterno (o un agente nuovo) parte da lì. **La lezione non è "tenere aggiornato il README"**,
che nessuno fa più di quanto già non faccia: è che **un reset invalida anche i documenti che nessuno
rilegge**, e l'inventario di cosa cita numeri morti va fatto il giorno del reset, quando si sa che sono
morti — non 75 giorni dopo, quando qualcuno ci inciampa per caso. Riscritto il 2026-09-02 contro lo stato
vero, con ogni riferimento a file verificato e i numeri nella lente di §2.
### Un flag sconosciuto non è l'azione di default (2026-09-02)
Nessuno dei 18 script di `scripts/live/` usava argparse: leggevano `sys.argv` con `in` e `index`,
quindi un flag sbagliato non era un errore — era il ramo `else`. Il costo si è misurato lo stesso
giorno, dentro una revisione: `trades_db.py --help` è caduto in `sync()` e ha riscritto
`meta.ultimo_sync`. Sui due script che scrivono davvero sarebbe stato peggio: la pagina di giornale
e una riga di DB, oppure una chiamata al modello più un Telegram. **La riparazione non è argparse**
cambierebbe messaggi d'errore, codici d'uscita e `--help` di script che il cron già chiama, cioè
romperebbe ciò che gira per riparare ciò che non gira mai. È una funzione di venti righe
(`src/live/cli.valida`) che rifiuta ciò che non è dichiarato ed esce **prima di qualunque effetto**:
`--help` → 0, flag ignoto → 2 con l'elenco dei previsti (P4). Il test deriva l'elenco degli script
dalla cartella (P1: un file nuovo senza guardia fallisce, non passa inosservato), controlla che
`valida` sia la prima istruzione di `__main__` (uscire dopo `connect()` sarebbe uscire dopo
l'effetto) e — l'altra metà del contratto, P15/P16 — che **i flag che il cron usa davvero restino
accettati**: una guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto.
### Cosa resta scoperto ### Cosa resta scoperto
1. **La sonda dice di chi è il guasto, non lo aggira.** Col gateway giù il libro continua ad 1. **La sonda dice di chi è il guasto, non lo aggira.** Col gateway giù il libro continua ad
+1 -1
View File
@@ -8,7 +8,7 @@
# MINUTO :25, e la scelta NON e' arbitraria: # MINUTO :25, e la scelta NON e' arbitraria:
# :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit # :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit
# Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo. # Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo.
# :07 cron_book.sh (esecuzione del book live). Il feed 5m di SKH01 passa da li': e' la cosa che # :47 cron_book.sh (esecuzione del book live; era :07 fino al 25/08). Il feed 5m di SKH01 passa da li': e' la cosa che
# NON deve trovare l'IP occupato. # NON deve trovare l'IP occupato.
# :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30. # :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30.
# #
+14
View File
@@ -115,7 +115,21 @@ def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True
return stato return stato
USO = """uso: analista.py [--giorno AAAA-MM-GG] [--modello NOME] [--secco] [--no-telegram] [--quiet]
(nessun flag) scrive l'analisi di OGGI nel campo `analisi` e manda la notifica
--giorno G analizza il giorno G
--modello M modello da usare (default: src.live.analista.MODELLO_DEFAULT)
--secco stampa e NON salva, niente Telegram
--no-telegram salva ma non manda la notifica
--quiet non stampa
SPENDE una chiamata al modello e MANDA un messaggio Telegram, salvo --secco/--no-telegram."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("analista.py", USO, flag=("--secco", "--no-telegram", "--quiet"),
con_valore=("--giorno", "--modello"))
con = T.connect() con = T.connect()
g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat())) g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat()))
st = scrivi(con, g, modello=_arg("--modello", A.MODELLO_DEFAULT), st = scrivi(con, g, modello=_arg("--modello", A.MODELLO_DEFAULT),
+11
View File
@@ -85,6 +85,17 @@ def campiona(c: DeribitRead) -> dict:
rec["n_posizioni"] = sum(1 for p in pos if abs(float(p.get("size") or 0)) > 1) rec["n_posizioni"] = sum(1 for p in pos if abs(float(p.get("size") or 0)) > 1)
except Exception: except Exception:
rec["nozionale_lordo"], rec["n_posizioni"] = None, None rec["nozionale_lordo"], rec["n_posizioni"] = None, None
# INDICE usde_usdc, ORARIO (aggiunto 2026-09-02, debito §5.15). `usde_watch` lo registra una
# volta al giorno (12:35Z): troppo rado per il classificatore dei movimenti di capitale, che
# confronta due letture di equity a un'ora di distanza e oggi guarda solo BTC/ETH — con il
# 31% dell'equity in USDE, un depeg sarebbe scorporato come un PRELIEVO. Questa e' la serie
# che manca; il cablaggio nel bound viene dopo, quando ci sara' storia. None se non leggibile
# (P5: "non vedo" non e' "il peg tiene").
try:
from src.live.usde import prezzo_indice
rec["usde_usdc"] = prezzo_indice()
except Exception as e:
rec["usde_usdc"], rec["usde_usdc_errore"] = None, f"{type(e).__name__}"
return rec return rec
+27 -3
View File
@@ -10,9 +10,24 @@ DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
data/live/book_executions.jsonl. data/live/book_executions.jsonl.
CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed CADENZA: ORARIA `47 * * * *` in `scripts/cron_book.sh` (il minuto e' spiegato li'). SKH01
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di decide su griglia 230m, ma il giro e' IDEMPOTENTE (riconcilia al target netto corrente; sotto
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book. `min_order_usd` $5 -> HOLD). Girare piu' fitto della griglia costa solo MICRO-ORDINI di ri-taglia:
il target e' ricalcolato ogni ora sull'equity marcata, e 22 dei 48 ordini live sono |delta|<=$10
a segnale invariato spiccioli oggi, ma il deadband e' in valuta ASSOLUTA (regola C2: a capitale
maggiore un movimento dello 0,15% lo supera ogni ora). Compra due cose misurate: (1) gli
ingressi/uscite SOFTWARE di SKH01 arrivano con latenza <=1h CON LA FEED 5m FRESCA: se e' stantia
`livefeed.fresh_5m` ripiega in silenzio sul feed giornaliero (latenza ~1 giorno) e
`skh_feed_max_age_min` 30 ALLERTA, non blocca; (2) il disaster-SL rotolante (-30%, l'unico on-book)
viene CONTROLLATO ogni ora e ri-ancorato solo oltre la tolleranza di `ensure_disaster_sl` (stop a
>5% da quello piazzato, cioe' mark +5,263%/-4,762%, o taglia >10%) — con questa isteresi
(`r0823_sl_anchor.py`): a cadenza 1h in 7-8 anni BTC 0 scatti / ETH 1; a 4h BTC 2; a 24h ETH 5,
con 2 in 30 giorni. Lo stop siede quindi fra -33,5% e -26,5% dal mark corrente (0,665-0,735 x mark),
non a -30% dall'ultima ora: e' rotolante, non un massimo di perdita (CLAUDE.md §1).
🚨 NON "correggere" la cadenza a ~230 minuti: fino al 2026-09-02 questo docstring lo prescriveva,
ed era il docstring a essere sbagliato, non il cron (debito §5.7). `tests/test_book_cadenza.py`
tiene d'accordo docstring, `cron_book.sh` e crontab installata. NON il cron giornaliero: le entrate
di SKH01 le mancherebbe.
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine) uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
@@ -340,5 +355,14 @@ def main():
raise raise
USO = """uso: book_execute.py [--execute]
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("book_execute.py", USO, flag=("--execute",), con_valore=())
main() main()
+8
View File
@@ -93,5 +93,13 @@ def main() -> None:
print(" regime QUIET: carry non raccoglibile (coerente con luglio 2026).") print(" regime QUIET: carry non raccoglibile (coerente con luglio 2026).")
USO = """uso: cc01_regime_watch.py
(nessun flag) sorveglia il regime di CC01 e stampa lo stato.
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("cc01_regime_watch.py", USO)
main() main()
+10
View File
@@ -144,5 +144,15 @@ def main() -> int:
return 0 return 0
USO = """uso: edge_watch.py [--quiet]
edge_watch.py sorveglianza dell'EDGE del book live. Riporta e allerta; non tocca nulla.
uv run python scripts/live/edge_watch.py # report
uv run python scripts/live/edge_watch.py --quiet # solo se qualcosa scatta (cron)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("edge_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+10
View File
@@ -252,5 +252,15 @@ def main() -> int:
return 2 if r["level"] == "AZIONE" else 0 return 2 if r["level"] == "AZIONE" else 0
USO = """uso: fee_watch.py [--quiet]
fee_watch.py sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
uv run python scripts/live/fee_watch.py # report
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("fee_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+12
View File
@@ -41,7 +41,19 @@ def una(con, g: date, nota: str | None = None, verbose: bool = True) -> Path:
return f return f
USO = """uso: journal.py [--giorno AAAA-MM-GG] [--backfill N] [--nota "testo"] [--quiet]
(nessun flag) scrive la voce di OGGI (pagina marcata PARZIALE: il giorno non e' chiuso)
--giorno G scrive la voce del giorno G
--backfill N ricostruisce gli ultimi N giorni, dal piu' vecchio
--nota "..." annota il giorno (campo dell'operatore, mai riscritto da un ricalcolo)
--quiet non stampa la riga di riepilogo
SCRIVE: una riga nel DB e una pagina in docs/journal/. Sola lettura su feed, log e venue."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("journal.py", USO, flag=("--quiet",), con_valore=("--giorno", "--backfill", "--nota"))
# sincronizzare PRIMA: altrimenti la sezione 'Libro' (letta dal log) e la sezione 'P&L' # sincronizzare PRIMA: altrimenti la sezione 'Libro' (letta dal log) e la sezione 'P&L'
# (letta dal DB) descrivono due istanti diversi e la pagina si contraddice da sola. # (letta dal DB) descrivono due istanti diversi e la pagina si contraddice da sola.
sys.path.insert(0, str(ROOT / "scripts" / "live")) sys.path.insert(0, str(ROOT / "scripts" / "live"))
+9
View File
@@ -137,5 +137,14 @@ def main():
raise raise
USO = """uso: live_execute.py [--execute]
uv run python scripts/live/live_execute.py # DRY-RUN (piano, nessun ordine)
uv run python scripts/live/live_execute.py --execute # esegue SOLO se execution_enabled=true
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("live_execute.py", USO, flag=("--execute",), con_valore=())
main() main()
+10
View File
@@ -74,5 +74,15 @@ def main():
(f"{len(r['orders'])} ordine/i costruito/i sopra." if r["orders"] else "Target flat: 0 ordini.")) (f"{len(r['orders'])} ordine/i costruito/i sopra." if r["orders"] else "Target flat: 0 ordini."))
USO = """uso: live_trend.py [--equity V] [--no-net]
uv run python scripts/live/live_trend.py # shadow su mainnet reale
uv run python scripts/live/live_trend.py --equity 2000 # forza la base di sizing
uv run python scripts/live/live_trend.py --no-net # offline: solo matematica + parita'
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("live_trend.py", USO, flag=("--no-net",), con_valore=("--equity",))
main() main()
+9
View File
@@ -88,5 +88,14 @@ def main():
print(" Validato: invio ordine reale, fill, fee reali, reconciliation, ritorno a flat.") print(" Validato: invio ordine reale, fill, fee reali, reconciliation, ritorno a flat.")
USO = """uso: microtest.py [--live]
uv run python scripts/live/microtest.py # DRY-RUN: nessun ordine inviato
uv run python scripts/live/microtest.py --live # invia il round-trip REALE
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("microtest.py", USO, flag=("--live",), con_valore=())
main() main()
+10
View File
@@ -55,5 +55,15 @@ def main() -> int:
return 0 return 0
USO = """uso: monitor_health.py [--quiet]
monitor_health.py i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
uv run python scripts/live/monitor_health.py # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+8
View File
@@ -118,5 +118,13 @@ def main():
print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}") print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}")
USO = """uso: paper_combo.py [--reset] [--status]
uv run python scripts/live/paper_combo.py [--status|--reset]
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("paper_combo.py", USO, flag=("--status", "--reset"), con_valore=())
main() main()
+10
View File
@@ -87,5 +87,15 @@ def main():
print(f" posizioni correnti: {pf.current_positions()}") print(f" posizioni correnti: {pf.current_positions()}")
USO = """uso: paper_portfolio.py [--reset] [--status]
uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run)
uv run python scripts/live/paper_portfolio.py --status # solo stato
uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("paper_portfolio.py", USO, flag=("--status", "--reset"), con_valore=())
main() main()
+10
View File
@@ -224,5 +224,15 @@ def main() -> None:
print("\n fatto. I file *.%s.* sono l'evidenza del difetto e restano nel backup." % SUFFISSO) print("\n fatto. I file *.%s.* sono l'evidenza del difetto e restano nel backup." % SUFFISSO)
USO = """uso: paper_regen.py [--solo V]
paper_regen.py RIGENERA le serie dei forward-monitor rotti da `advance()` (§5.1).
uv run python scripts/live/paper_regen.py # tutti e 4
uv run python scripts/live/paper_regen.py --solo paper_statarb
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("paper_regen.py", USO, flag=(), con_valore=("--solo",))
main() main()
+10
View File
@@ -58,5 +58,15 @@ def main() -> None:
print(f"\n invio: {rep['invio']}") print(f"\n invio: {rep['invio']}")
USO = """uso: scale_watch.py [--quiet] [--secco]
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)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("scale_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
main() main()
+9
View File
@@ -146,5 +146,14 @@ def main() -> None:
print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)") print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)")
USO = """uso: telegram_daily.py [--dry-run]
uv run python scripts/live/telegram_daily.py # calcola e invia
uv run python scripts/live/telegram_daily.py --dry-run # stampa e basta, non invia
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("telegram_daily.py", USO, flag=("--dry-run",), con_valore=())
main() main()
+18 -3
View File
@@ -91,8 +91,15 @@ def report() -> None:
print(f" movimenti capitale : {r['certi']:+,.2f} certi | {r['ambigui']:+,.2f} ambiguo/i" print(f" movimenti capitale : {r['certi']:+,.2f} certi | {r['ambigui']:+,.2f} ambiguo/i"
f" (restano nel P&L, dichiarati)") f" (restano nel P&L, dichiarati)")
for ev in r["eventi"]: for ev in r["eventi"]:
print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}]") mk = ev.get("mercato_max_pct")
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti certi)") dettaglio = (ev.get("mercato_nota") or "mercato non misurabile") if mk is None \
else f"mercato max {100 * mk:.2f}% a leva piena, salto {100 * ev['pct']:+.1f}%"
print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}: {dettaglio}]")
if r["trading"] is None:
print(f" trading da arming : n/d ({r['motivo']})")
else:
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti "
f"certi; l'ora del movimento esce intera, vedi journal.rendimento_twr)")
if r["twr"] is None: if r["twr"] is None:
print(f" TWR : n/d ({r['motivo']})") print(f" TWR : n/d ({r['motivo']})")
else: else:
@@ -118,8 +125,16 @@ def report() -> None:
con.close() con.close()
USO = """uso: trades_db.py [--sync] [--report] [--reconcile] [--quiet]
--sync (default) legge log e jsonl e aggiorna data/live/trades.db idempotente
--report stato del libro di bordo + P&L (TWR, non e1/e0)
--reconcile incrocio delle tre fonti sui fill (log x jsonl x venue)
--quiet con --sync: non stampa il riepilogo"""
if __name__ == "__main__": if __name__ == "__main__":
args = sys.argv[1:] from src.live.cli import valida
args = valida("trades_db.py", USO, flag=("--sync", "--report", "--reconcile", "--quiet"))
if "--reconcile" in args: if "--reconcile" in args:
reconcile() reconcile()
elif "--report" in args: elif "--report" in args:
+10
View File
@@ -330,5 +330,15 @@ def main() -> int:
return 0 return 0
USO = """uso: usde_watch.py [--quiet]
usde_watch.py sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA.
uv run python scripts/live/usde_watch.py # report completo
uv run python scripts/live/usde_watch.py --quiet # stampa/allerta solo transizioni
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("usde_watch.py", USO, flag=("--quiet",), con_valore=())
sys.exit(main()) sys.exit(main())
+10
View File
@@ -98,5 +98,15 @@ def main() -> int:
return 0 return 0
USO = """uso: venue_watch.py [--quiet]
venue_watch.py runner ORARIO del tripwire di venue. Allerta su Telegram, NON blocca nulla.
uv run python scripts/live/venue_watch.py # un giro, stampa il report
uv run python scripts/live/venue_watch.py --quiet # solo in caso di allarme (per il cron)
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida
valida("venue_watch.py", USO, flag=("--quiet",), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+11 -10
View File
@@ -17,7 +17,7 @@ SOLA LETTURA. Questo script:
- non importa nulla che invii ordini se non per ISPEZIONARLO (la sottoclasse di §1 ha `_post` che - non importa nulla che invii ordini se non per ISPEZIONARLO (la sottoclasse di §1 ha `_post` che
SOLLEVA: se il codice di produzione toccasse la rete, il test fallirebbe rumorosamente); SOLLEVA: se il codice di produzione toccasse la rete, il test fallirebbe rumorosamente);
- tocca il venue solo con `--venue`, solo `get_open_orders` (lettura), 2 richieste, e si rifiuta - tocca il venue solo con `--venue`, solo `get_open_orders` (lettura), 2 richieste, e si rifiuta
di partire nei minuti :05-:10 (cron_book) e :24-:30 (cron_chain). di partire nei minuti :45-:50 (cron_book, al :47 dal 25/08) e :24-:30 (cron_chain).
uv run python scripts/research/r0823_sl_anchor.py # attesa + log + simulazione uv run python scripts/research/r0823_sl_anchor.py # attesa + log + simulazione
uv run python scripts/research/r0823_sl_anchor.py --venue # + riscontro sul conto (2 letture) uv run python scripts/research/r0823_sl_anchor.py --venue # + riscontro sul conto (2 letture)
@@ -283,8 +283,8 @@ def riscontro_venue(runs_info: dict):
diritte di Deribit appartengono a un ALTRO progetto: usarle sarebbe inventare un percorso, e la diritte di Deribit appartengono a un ALTRO progetto: usarle sarebbe inventare un percorso, e la
domanda non ne ha bisogno. Si usa quindi cio' che il percorso sanzionato offre: `get_open_orders`.""") domanda non ne ha bisogno. Si usa quindi cio' che il percorso sanzionato offre: `get_open_orders`.""")
m = datetime.now(timezone.utc).minute m = datetime.now(timezone.utc).minute
if 5 <= m <= 10 or 24 <= m <= 30: if 45 <= m <= 50 or 24 <= m <= 30: # cron_book al :47 dal 2026-08-25 (era :07), cron_chain :25
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:07) / cron_chain (:25). Non interrogo.") print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:47) / cron_chain (:25). Non interrogo.")
return return
from src.live.deribit import DeribitRead from src.live.deribit import DeribitRead
r = DeribitRead() r = DeribitRead()
@@ -395,10 +395,11 @@ def conseguenza():
alta dopo una salita, lo stop siede vicino sotto il mercato, il crollo lo prende, si rientra e alta dopo una salita, lo stop siede vicino sotto il mercato, il crollo lo prende, si rientra e
la gamba successiva lo riprende. **Il rischio di accumulo e' funzione della CADENZA DEL CRON, la gamba successiva lo riprende. **Il rischio di accumulo e' funzione della CADENZA DEL CRON,
non di k**: il gradino di leva non lo crea e non lo amplifica. non di k**: il gradino di leva non lo crea e non lo amplifica.
· E qui c'e' un aggancio operativo: il docstring di `scripts/live/book_execute.py` prescrive · Aggancio operativo (RIPARATO il 2026-09-02, `tests/test_book_cadenza.py`): fino a quel
«ogni ~230 minuti» (la griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giorno il docstring di `scripts/live/book_execute.py` prescriveva «ogni ~230 minuti» (la
giri in 1442 ore). Chi "correggesse" la cadenza verso il docstring sposterebbe il libro dalla griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giri in 1442 ore).
riga 1h alla riga 4h quella in cui BTC raddoppia gli scatti. Chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro dalla riga 1h
(rotolante: 0 scatti) alla riga 4h (2 scatti del rotolante contro 1 del pavimento).
(*) CAVEAT sulla colonna, e sul suo artefatto: in questa lente la posizione e' SEMPRE aperta (*) CAVEAT sulla colonna, e sul suo artefatto: in questa lente la posizione e' SEMPRE aperta
dal 2018, quindi in modo `roll` l'"ingresso" e' un prezzo di anni prima e il -48%/-61% e' il dal 2018, quindi in modo `roll` l'"ingresso" e' un prezzo di anni prima e il -48%/-61% e' il
@@ -501,9 +502,9 @@ def costo(out: dict):
E c'e' un accoppiamento nuovo da registrare: l'accumulo di episodi e' funzione della CADENZA E c'e' un accoppiamento nuovo da registrare: l'accumulo di episodi e' funzione della CADENZA
DEL CRON (1h: no · 4h: BTC 2 · 24h: ETH 5 con 2 in 30 giorni). Oggi la cadenza e' misurata sana, DEL CRON (1h: no · 4h: BTC 2 · 24h: ETH 5 con 2 in 30 giorni). Oggi la cadenza e' misurata sana,
ma nessun sorvegliante la controlla contro questa conseguenza, e il docstring di `book_execute` e dal 2026-09-02 `tests/test_book_cadenza.py` tiene d'accordo docstring, cron_book.sh e crontab
ne prescrive una (~230 min) che cade nella riga peggiore. Non e' un'azione richiesta qui: e' il (il docstring ne prescriveva una, ~230 min, nella riga 4h). Resta il parametro da rileggere prima
parametro da rileggere prima del gradino, insieme a G2.""") del gradino, insieme a G2.""")
def main(): def main():
+4 -1
View File
@@ -205,7 +205,10 @@ def testata(voce: dict) -> str:
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"] r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d") r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
+ ("" if dq is None else f" ({dq:+.2f} oggi)") + ("" if dq is None else f" ({dq:+.2f} oggi)")
+ ("" if cum is None else f" · dall'arming {cum:+.2f}")) + ("" if cum is None else f" · dall'arming {cum:+.2f}"
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
f"{p['trading_da_arming']:+.2f})"
if p.get("mov_arming") and p.get("trading_da_arming") is not None else "")))
if "errore" not in lb and lb.get("asset"): if "errore" not in lb and lb.get("asset"):
pos = " · ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted(lb["asset"].items())) pos = " · ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted(lb["asset"].items()))
r.append(f" {pos} · leva {lb.get('leva_lorda') or 0:.2f}x") r.append(f" {pos} · leva {lb.get('leva_lorda') or 0:.2f}x")
+66
View File
@@ -0,0 +1,66 @@
"""Argomenti da riga di comando degli script operativi: un flag ignoto NON e' l'azione di default.
PERCHE' ESISTE (2026-09-02). Nessuno degli script di `scripts/live/` usava argparse: leggevano
`sys.argv` con `in` e `index`, quindi un flag sbagliato non era un errore era il ramo `else`.
Costo misurato lo stesso giorno, durante una revisione: `trades_db.py --help` non ha stampato
l'uso, e' caduto in `sync()` e ha riscritto `meta.ultimo_sync`. Gli altri due sono peggio:
`journal.py --help` avrebbe scritto la pagina di oggi e una riga nel DB, `analista.py --help`
avrebbe speso una chiamata al modello e mandato un messaggio Telegram.
TRE REGOLE, e nient'altro:
1. **Nessun argparse.** Gli script hanno flag posizionali-per-nome (`--report`, `--secco`) e
flag con valore (`--giorno 2026-08-21`); argparse cambierebbe i messaggi d'errore, il
codice d'uscita e il comportamento di `--help` di 18 script che il cron gia' chiama. Qui
serve il contrario: **lasciare intatto cio' che gira** e rifiutare cio' che non e' previsto.
2. **Il cron non deve cambiare.** I flag dichiarati sono quelli che gli script gia' accettano:
`valida()` non ne aggiunge e non ne toglie (P15: una regola si prova contro il codice che
dovrebbe eseguirla). L'unica novita' e' `--help`, che prima non esisteva.
3. **Si esce PRIMA di qualunque effetto.** `valida()` va chiamata come prima istruzione del
blocco `__main__`, prima di `connect()`, prima del sync, prima della rete.
Codici d'uscita: 0 per `--help` (l'utente ha chiesto l'uso), 2 per un flag ignoto (errore d'uso,
distinto dal 1 con cui gli script segnalano un esito negativo del loro lavoro).
"""
from __future__ import annotations
import sys
class UsoSbagliato(Exception):
"""Flag non previsto da questo script. Portato con l'elenco di cio' che e' previsto (P4)."""
def valida(nome: str, uso: str, *, flag: tuple[str, ...] = (), con_valore: tuple[str, ...] = (),
argv: list[str] | None = None, esci: bool = True) -> list[str]:
"""Controlla `argv` contro i flag dichiarati. Ritorna gli argomenti; solleva o esce se ignoti.
`flag` sono i booleani (`--quiet`), `con_valore` quelli che consumano l'argomento seguente
(`--giorno 2026-08-21`). `--help`/`-h` stampano `uso` ed escono 0. Con `esci=False` non
tocca `sys.exit` e solleva `UsoSbagliato`: e' la forma che i test usano.
"""
args = list(sys.argv[1:] if argv is None else argv)
if "--help" in args or "-h" in args:
if not esci:
return args
print(uso.strip())
raise SystemExit(0)
noti, ignoti, attesa_valore = set(flag) | set(con_valore), [], False
for a in args:
if attesa_valore: # e' il valore di un flag noto, non un flag
attesa_valore = False
continue
if not a.startswith("-"):
ignoti.append(a); continue # posizionale: nessuno di questi script ne ha
if a not in noti:
ignoti.append(a); continue
attesa_valore = a in con_valore
if attesa_valore:
ignoti.append(f"{args[-1]} (manca il valore)")
if ignoti:
msg = (f"{nome}: argomento non previsto: {', '.join(ignoti)}\n"
f" previsti: {' '.join(sorted(noti)) or '(nessuno)'}\n\n{uso.strip()}")
if not esci:
raise UsoSbagliato(msg)
print(msg, file=sys.stderr)
raise SystemExit(2)
return args
+79 -24
View File
@@ -118,7 +118,7 @@ def _px_1h(asset: str) -> pd.Series | None:
return None return None
def movimenti_capitale(con, fino_ts: str) -> dict: def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
"""Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L. """Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L.
PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce
@@ -139,8 +139,11 @@ def movimenti_capitale(con, fino_ts: str) -> dict:
non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live. non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live.
""" """
from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc", # `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron
(fino_ts,)).fetchall() # possono cadere ai due lati di una scrittura e descrivere due istanti diversi.
if rows is None:
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(fino_ts,)).fetchall()
soglia = float(_book.EQUITY_JUMP_ALERT) soglia = float(_book.EQUITY_JUMP_ALERT)
candidati = [(a, b) for a, b in zip(rows, rows[1:]) candidati = [(a, b) for a, b in zip(rows, rows[1:])
if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia] if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia]
@@ -148,30 +151,48 @@ def movimenti_capitale(con, fino_ts: str) -> dict:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia) return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia)
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
leva_tetto = float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) # la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
# 01/09 entra qui da sola)
scala = float(cfg.get("book_scale_k") or 1.0)
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
float(_book.LEVA_LORDA_MAX))
except Exception: except Exception:
leva_tetto = 1.0 # il tetto di progetto: bound conservativo, usato solo qui leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
px = {a: _px_1h(a) for a in ASSETS} px = {a: _px_1h(a) for a in ASSETS}
eventi = [] eventi = []
for prev, cur in candidati: for prev, cur in candidati:
t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"]) t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"])
delta = float(cur["equity"] - prev["equity"]) delta = float(cur["equity"] - prev["equity"])
pct = delta / prev["equity"] pct = delta / prev["equity"]
mosse = [] # Il mercato e' MISURABILE solo se il feed copre entrambe le letture. Il feed 1h
# certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
# `asof` restituirebbe la STESSA barra per t0 e t1, max_mkt 0, e qualunque calo >=10%
# diventerebbe un "movimento" — un crash stampato come prelievo (revisione 02/09).
# Non misurabile => `ambiguo` con la ragione (P5: "non vedo" non e' "mercato fermo").
mosse, non_mis = [], []
for a in ASSETS: for a in ASSETS:
s = px.get(a) s = px.get(a)
if s is None or s.empty: if s is None or s.empty:
continue non_mis.append(f"{a}: feed 1h non leggibile"); continue
fine = s.index[-1] + pd.Timedelta(hours=1) # l'ultima barra copre un'ora
if t0 < s.index[0] or t1 > fine:
non_mis.append(f"{a}: feed 1h fermo a {s.index[-1]:%Y-%m-%dT%H:%M}Z"); continue
p0, p1 = s.asof(t0), s.asof(t1) p0, p1 = s.asof(t0), s.asof(t1)
if p0 and p1 and not (pd.isna(p0) or pd.isna(p1)): if pd.isna(p0) or pd.isna(p1) or not p0 or not p1:
mosse.append(abs(float(p1) / float(p0) - 1.0)) non_mis.append(f"{a}: barra assente"); continue
max_mkt = max(mosse) * leva_tetto if mosse else None mosse.append(abs(float(p1) / float(p0) - 1.0))
classe = "movimento" if (max_mkt is not None and abs(pct) > 2.0 * max_mkt) else "ambiguo" if non_mis:
max_mkt, classe, nota = None, "ambiguo", "mercato non misurabile: " + "; ".join(non_mis)
else:
max_mkt = max(mosse) * leva_tetto
classe = "movimento" if abs(pct) > 2.0 * max_mkt else "ambiguo"
nota = None
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"], eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
prima=float(prev["equity"]), dopo=float(cur["equity"]), prima=float(prev["equity"]), dopo=float(cur["equity"]),
delta=delta, pct=float(pct), delta=delta, pct=float(pct),
mercato_max_pct=(None if max_mkt is None else float(max_mkt)), mercato_max_pct=(None if max_mkt is None else float(max_mkt)),
classe=classe)) mercato_nota=nota, classe=classe))
return dict(eventi=eventi, return dict(eventi=eventi,
certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")), certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")),
ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")), ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")),
@@ -191,31 +212,57 @@ def rendimento_twr(con, fino_ts: str) -> dict:
apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli
eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto). eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto).
TRE STATI: `twr` e' un numero, oppure None con `motivo` (serie vuota, base zero). LIMITE DICHIARATO (D5). L'intervallo che CONTIENE un movimento certo esce INTERO dal
rendimento: il suo P&L di mercato (marcatura delle posizioni aperte, funding; in un buco di
lettura fino a 10h nella serie vera l'intero buco) finisce in `certi`, quindi anche fuori
da `trading`. E' limitato per costruzione dal margine del classificatore (|salto| >
2·max_mkt·leva·equity), percio' l'errore massimo vale max_mkt·leva·equity: fino a META' del
movimento riconosciuto. Il 25/08: libro a ~$263 lordi, mercato a ±0,2% -> ~$0,5 su $1.399,39.
Non si stima e non si corregge (P12): si dichiara. Un intervallo di sola marcatura fra due
movimenti consecutivi, o prima del primo, ha lunghezza zero e NON produce un segmento.
TRE STATI: `twr` e `trading` sono numeri, oppure None con `motivo`. Mercato NON misurabile
per almeno un salto (feed 1h fermo, o assente) => entrambi None: non si conosce la classe
del salto, quindi non si conosce il rendimento e `e1/e0-1` non torna sotto un'etichetta
piu' autorevole. Base di equity zero =>
entrambi None: il salto 0 -> X del primo versamento e' INVISIBILE a `movimenti_capitale`
(una base zero non ha percentuale), quindi `certi` lo perderebbe e `trading` varrebbe
l'intero conto — un numero etichettato "al netto dei versamenti" che li contiene tutti.
""" """
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc", rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
(fino_ts,)).fetchall() (fino_ts,)).fetchall()
if not rows: if not rows:
return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[], return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[],
certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0) certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0)
mv = movimenti_capitale(con, fino_ts) mv = movimenti_capitale(con, fino_ts, rows=rows) # stessa lettura, un solo istante
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"]) e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"])
if e0 <= 0:
return dict(twr=None, trading=None,
motivo="base di equity nulla: il primo versamento non e' classificabile",
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
delta_equity=e1 - e0, letture=len(rows))
non_mis = [e for e in mv["eventi"] if e.get("mercato_nota")]
if non_mis:
# un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un
# movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe
# sotto l'etichetta "TWR", il numero che il debito 14 e' nato per uccidere)
return dict(twr=None, trading=None,
motivo=(f"{len(non_mis)} salto/i non classificabile/i — "
+ non_mis[0]["mercato_nota"]),
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
delta_equity=e1 - e0, letture=len(rows))
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0 segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0
for ev in certi: # gia' in ordine di tempo for ev in certi: # gia' in ordine di tempo
if da_eq: if da_eq and ev["ts_prima"] != da_ts: # lunghezza zero = niente segmento
segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"], segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"],
ret=ev["prima"] / da_eq - 1.0)) ret=ev["prima"] / da_eq - 1.0))
da_ts, da_eq = ev["ts_dopo"], ev["dopo"] da_ts, da_eq = ev["ts_dopo"], ev["dopo"]
if da_eq: if da_eq and rows[-1]["ts_utc"] != da_ts:
segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1, segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1,
ret=e1 / da_eq - 1.0)) ret=e1 / da_eq - 1.0))
if not segmenti or e0 <= 0:
return dict(twr=None, motivo="base di equity nulla", segmenti=segmenti, eventi=mv["eventi"],
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
trading=e1 - e0 - mv["certi"], letture=len(rows))
twr = 1.0 twr = 1.0
for sg in segmenti: for sg in segmenti: # nessun segmento = nessun tempo a mercato = 0
twr *= 1.0 + sg["ret"] twr *= 1.0 + sg["ret"]
return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"], return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"],
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0, certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
@@ -240,7 +287,10 @@ def pnl_giorno(con, giorno: date) -> dict:
e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone() e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone()
delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None
cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None
mv = movimenti_capitale(con, f"{g}T23:59:59+00:00") # il cumulato dall'arming, i movimenti e il trading al netto vengono da rendimento_twr:
# UNA funzione per i due lettori (report e giornale), non due aritmetiche (revisione 02/09)
r_twr = rendimento_twr(con, f"{g}T23:59:59+00:00")
mv = dict(eventi=r_twr["eventi"], certi=r_twr["certi"], ambigui=r_twr["ambigui"])
ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g] ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g]
mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento") mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento")
amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo") amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo")
@@ -254,7 +304,8 @@ def pnl_giorno(con, giorno: date) -> dict:
movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g), movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g),
trading_giorno=(delta - mov_g) if delta is not None else None, trading_giorno=(delta - mov_g) if delta is not None else None,
movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"], movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"],
trading_da_arming=(cum - mv["certi"]) if cum is not None else None) trading_da_arming=(r_twr["trading"] if cum is not None else None),
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
def salute(con, giorno: date) -> dict: def salute(con, giorno: date) -> dict:
@@ -626,6 +677,10 @@ def rendi_markdown(con, voce: dict) -> str:
if p.get("mov_arming"): if p.get("mov_arming"):
riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading " riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading "
f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**") f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**")
if p.get("twr_da_arming") is not None:
riga_c += f" · TWR **{100 * p['twr_da_arming']:+.2f}%**"
elif p.get("twr_motivo"):
riga_c += f" · TWR n/d ({p['twr_motivo']})"
if p.get("mov_arming_ambiguo"): if p.get("mov_arming_ambiguo"):
riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti" riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti"
L += ["", "## P&L", "", L += ["", "## P&L", "",
+34
View File
@@ -0,0 +1,34 @@
"""Utilita' condivise dei test. `tests/` e' su sys.path (c'e' un conftest), quindi si importa
con `from helpers import carica_script`.
PERCHE' ESISTE (2026-09-02). `scripts/` non e' un package importabile per nome, quindi ogni test
che tocca uno script si caricava il modulo a mano con `importlib.util.spec_from_file_location`:
**15 copie della stessa dozzina di righe**, con nomi di modulo diversi per lo stesso file e
differenze silenziose (chi registra in `sys.modules` e chi no e chi non lo fa rompe `pickle`,
`dataclasses` e i decoratori che risolvono per nome). Una funzione sola, con la registrazione
fatta sempre.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def carica_script(rel: str, nome: str | None = None):
"""Carica per PERCORSO uno script del repo (es. "scripts/live/trades_db.py").
`nome` e' il nome sotto cui il modulo viene registrato in `sys.modules`; il default e' il
nome del file. Registrare PRIMA di eseguire e' voluto: un modulo che si auto-importa o usa
`dataclasses` durante l'esecuzione deve trovarsi.
"""
p = (ROOT / rel).resolve()
assert p.exists(), f"script inesistente: {rel}"
nome = nome or p.stem
spec = importlib.util.spec_from_file_location(nome, p)
mod = importlib.util.module_from_spec(spec)
sys.modules[nome] = mod
spec.loader.exec_module(mod)
return mod
+207
View File
@@ -0,0 +1,207 @@
"""La cadenza del libro live e' ORARIA, e le tre dichiarazioni devono restare d'accordo.
Debito §5.7 (chiuso il 2026-09-02): il docstring di `book_execute.py` prescriveva «ogni ~230
minuti» mentre il cron gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che gli
scatti del disaster-SL rotolante dipendono dalla cadenza 1h: BTC 0 / ETH 1 in 7-8 anni; 4h:
BTC 2 (contro 1 del pavimento); 24h: ETH 5 con 2 in 30 giorni. Chi avesse "corretto" il CRON
verso il docstring avrebbe spostato il libro dalla riga 1h a quella 4h.
P1: il bersaglio si DERIVA da cio' che gira. Qui le fonti sono tre — il docstring (cio' che un
umano legge), `cron_book.sh` (cio' che dichiara di essere installato) e la crontab (cio' che
gira davvero) e il test le confronta fra loro invece di ridichiarare «60 minuti».
"""
from __future__ import annotations
import ast
import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import venue_probe as VP # noqa: E402 lo slot di release, dichiarato li'
from src.strategies.skyhook import LTF_MIN # noqa: E402 la griglia di SKH01, NON ridichiarata
BOOK_EXECUTE = ROOT / "scripts" / "live" / "book_execute.py"
CRON_BOOK = ROOT / "scripts" / "cron_book.sh"
RIGA_4H_MIN = 240 # la riga in cui il rotolante di BTC passa da 0 a 2 scatti (r0823_sl_anchor.py);
# la PEGGIORE misurata e' 24h (ETH 5, 2 in 30 giorni), non questa
_RX_CRON5 = re.compile(r"`([\d*/,-]+(?: [\d*/,-]+){4})`")
def cadenza_minuti(expr: str) -> int:
"""Minuti fra due esecuzioni di un'espressione crontab a 5 campi, SOLO per cadenze regolari
(intervalli tutti uguali): `47 * * * *` -> 60, `*/30 * * * *` -> 30, `7 */4 * * *` -> 240.
`*/45` (intervalli 45/15), `0 */5` (ultimo intervallo 4h) e `*/0` vengono RIFIUTATI."""
campi = expr.split()
assert len(campi) == 5, expr
m, h, dom, mon, dow = campi
assert dom == mon == dow == "*", f"cadenza non regolare: {expr}"
if h == "*" and m.isdigit():
return 60
if h == "*" and m.startswith("*/"):
passo = int(m[2:])
if passo <= 0 or 60 % passo:
raise ValueError(f"passo di minuti non regolare: {expr}")
return passo
if h.startswith("*/") and m.isdigit():
passo = int(h[2:])
if passo <= 0 or 24 % passo:
raise ValueError(f"passo di ore non regolare: {expr}")
return 60 * passo
raise ValueError(f"espressione non riconosciuta: {expr}")
def _docstring() -> str:
return ast.get_docstring(ast.parse(BOOK_EXECUTE.read_text())) or ""
def _cron_dichiarato() -> str:
"""La prima espressione crontab a 5 campi che `cron_book.sh` dichiara fra backtick — di
qualunque forma: e' `cadenza_minuti` a giudicarla, non questa regex (P4: un'espressione
presente ma non oraria deve fallire come 'non oraria', non come 'assente')."""
m = _RX_CRON5.search(CRON_BOOK.read_text())
assert m, "cron_book.sh non dichiara piu' la sua riga di crontab fra backtick"
return m.group(1)
def _crontab() -> str | None:
"""Testo della crontab dell'utente, None se non leggibile (P5: distinto da 'vuota')."""
if not shutil.which("crontab"):
return None
try:
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
except Exception:
return None
return out.stdout if out.returncode == 0 else None
def _righe_attive(testo: str, marcatore: str) -> list[str]:
"""Le espressioni (5 campi) di TUTTE le righe attive che citano `marcatore`: due righe sono
due esecuzioni, e la seconda non deve nascondersi dietro la prima."""
return [" ".join(r.split()[:5]) for r in testo.splitlines()
if marcatore in r and not r.lstrip().startswith("#")]
# ---------------------------------------------------------------- regressione sul docstring
# La guardia non cerca due letterali («ogni ~230»): estrae OGNI prescrizione di cadenza scritta
# come «ogni/every N unita'» e pretende che valga 60 minuti. ⚠️ Limite dichiarato (P13): una
# guardia sui numeri non copre il ragionamento — una prosa che prescrive una cadenza senza la
# parola «ogni» passa. Il controllo positivo qui sotto dice cosa la guardia VEDE.
_NUMERI = {"un": 1, "una": 1, "due": 2, "tre": 3, "quattro": 4, "sei": 6, "otto": 8, "dodici": 12}
_RX_CADENZA = re.compile(
r"\b(?:ogni|every)\s*~?\s*(\d+|un'?|una|due|tre|quattro|sei|otto|dodici)?\s*'?\s*"
r"(minuti|minuto|minutes?|min|m|ore|ora|hours?|h)\b", re.I)
def prescrizioni_minuti(testo: str) -> list[int]:
"""Ogni «ogni N unita'» del testo, in minuti. «ogni ora» -> 60, «ogni ~230 minuti» -> 230."""
out = []
for m in _RX_CADENZA.finditer(testo):
n_raw, unita = m.group(1), m.group(2).lower()
n = 1 if n_raw is None else (int(n_raw) if n_raw.isdigit() else _NUMERI[n_raw.rstrip("'")])
out.append(n * (60 if unita.startswith(("or", "h")) else 1))
return out
def test_la_guardia_vede_le_prescrizioni_che_deve_vedere():
# controllo positivo (M15): i modi in cui il docstring potrebbe ri-prescrivere la riga 4h
assert prescrizioni_minuti("va lanciato ogni ~230 minuti con la feed") == [230]
assert prescrizioni_minuti("il giro va lanciato ogni quattro ore") == [240]
assert prescrizioni_minuti("every 4 hours, then every 230 min") == [240, 230]
assert prescrizioni_minuti("controllato ogni ora; ogni 60 minuti") == [60, 60]
assert prescrizioni_minuti("fino a 230m di latenza") == [] # non e' una prescrizione
def test_il_docstring_prescrive_solo_la_cadenza_oraria():
d = _docstring()
p = prescrizioni_minuti(d)
assert p, "il docstring deve contenere almeno una prescrizione di cadenza"
assert all(x == 60 for x in p), f"prescrizioni non orarie nel docstring: {p}"
assert re.search(r"CADENZA:\s*ORARIA", d), "il docstring deve dichiarare la cadenza ORARIA"
def test_il_docstring_dichiara_la_stessa_riga_del_cron():
# la riga di crontab citata nel docstring e' quella di cron_book.sh, non un'altra
d = _docstring()
assert _cron_dichiarato() in d
# ---------------------------------------------------------------- cron_book.sh
def test_cron_book_e_orario_sotto_la_griglia_skh_e_sotto_la_riga_4h():
c = cadenza_minuti(_cron_dichiarato())
assert c == 60
assert c < LTF_MIN, "la cadenza deve stare SOTTO la griglia di SKH01 (latenza <= 1 barra)"
assert c < RIGA_4H_MIN, "la riga 4h e' quella in cui il rotolante di BTC fa 2 scatti"
assert "ORARIA" in CRON_BOOK.read_text()
def test_cron_book_evita_il_minuto_tondo():
# vincolo 1 del commento di cron_book.sh: fuori dai ~26s del :00 (rate-limit per-IP)
minuto = int(_cron_dichiarato().split()[0])
assert minuto != 0
def test_cron_book_evita_lo_slot_di_release_deribit():
# vincolo 2 del commento di cron_book.sh — il motivo vero del :07 -> :47 (4 giri morti in
# 63 giorni): il giro del martedi' alle 09:MM deve cadere FUORI dallo slot dichiarato in
# venue_probe (derivato, non riscritto). Il :07 lo violava; se qualcuno tornasse a :15
# con docstring e crontab coerenti, gli altri test passerebbero e questo no.
minuto = int(_cron_dichiarato().split()[0])
base = datetime(2026, 9, 1, tzinfo=timezone.utc) # un lunedi'
martedi = base + timedelta(days=(VP.RELEASE_WEEKDAY - base.weekday()) % 7)
giro = martedi.replace(hour=VP.RELEASE_START_MIN // 60, minute=minuto)
assert not VP.in_release_window(giro), f"il giro :{minuto:02d} cade nello slot di release"
assert VP.in_release_window(giro.replace(minute=7)), "controllo positivo: il :07 ci cadeva"
@pytest.mark.parametrize("expr,atteso", [("47 * * * *", 60), ("*/30 * * * *", 30),
("7 */4 * * *", 240), ("0 */24 * * *", 1440)])
def test_il_parser_della_cadenza(expr, atteso):
assert cadenza_minuti(expr) == atteso
@pytest.mark.parametrize("expr", ["47 9 * * 2", "*/45 * * * *", "0 */5 * * *", "*/0 * * * *",
"47 * 1 * *"])
def test_il_parser_rifiuta_cio_che_non_e_regolare(expr):
with pytest.raises((ValueError, AssertionError)):
cadenza_minuti(expr)
# ---------------------------------------------------------------- la crontab che GIRA
def test_la_crontab_installata_e_quella_dichiarata_e_una_sola():
"""P16: il cron gira dalla macchina, non dal repo. TRE stati, non due (P5):
- crontab NON leggibile -> SALTATO (non verificabile qui);
- leggibile, e questo progetto non ci compare affatto -> SALTATO (macchina di sviluppo);
- leggibile con altre righe del progetto ma SENZA cron_book.sh attivo -> ROSSO: il libro
non e' schedulato dove il progetto e' installato (una riga commentata per manutenzione e
dimenticata e' esattamente questo caso).
E la riga deve essere UNA: due righe attive sono due esecuzioni l'ora."""
testo = _crontab()
if testo is None:
pytest.skip("crontab non leggibile: non verificabile qui")
if str(ROOT) not in testo and ROOT.name not in testo:
pytest.skip("la crontab non cita questo progetto: non e' la macchina che lo esegue")
righe = _righe_attive(testo, "cron_book.sh")
assert righe, "questa macchina esegue il progetto ma cron_book.sh NON e' schedulato"
assert len(righe) == 1, f"cron_book.sh schedulato {len(righe)} volte: {righe}"
assert righe[0] == _cron_dichiarato()
assert cadenza_minuti(righe[0]) == 60
def test_due_righe_attive_non_si_nascondono_dietro_la_prima():
finta = ("47 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"# 7 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
"17 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n")
assert _righe_attive(finta, "cron_book.sh") == ["47 * * * *", "17 * * * *"]
assert _righe_attive("# 47 * * * * /x/cron_book.sh\n", "cron_book.sh") == []
+9 -15
View File
@@ -192,12 +192,9 @@ def test_gate_requires_both_switches():
def test_config_default_disarmed(tmp_path, monkeypatch): def test_config_default_disarmed(tmp_path, monkeypatch):
"""load_config di book_execute mette execution_enabled=False di default (fail-safe).""" """load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
import importlib # `scripts/` non e' un package importabile per nome: si carica via percorso
be = importlib.import_module("scripts.live.book_execute") if False else None from helpers import carica_script
# carica il modulo via path (scripts/ non e' un package importabile per nome) mod = carica_script("scripts/live/book_execute.py")
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
assert mod.load_config()["execution_enabled"] is False assert mod.load_config()["execution_enabled"] is False
@@ -419,9 +416,8 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
def test_book_execute_surfaces_skh_error(monkeypatch, capsys): def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso).""" """Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
import importlib.util from helpers import carica_script
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py") mod = carica_script("scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = dict( canned = dict(
last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test", last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test",
@@ -481,9 +477,8 @@ def test_book_report_propagates_pos_error(monkeypatch):
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys): def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader.""" """ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
import importlib.util from helpers import carica_script
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py") mod = carica_script("scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = dict( canned = dict(
last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC", last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC",
@@ -542,9 +537,8 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys): def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error).""" """eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
import importlib.util from helpers import carica_script
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py") mod = carica_script("scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = dict( canned = dict(
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0, last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
+2 -5
View File
@@ -116,11 +116,8 @@ def test_posizione_flat_non_puo_diventare_naked():
# B. Isolamento per asset dentro book_execute._run(). # B. Isolamento per asset dentro book_execute._run().
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _carica_book_execute(): def _carica_book_execute():
spec = importlib.util.spec_from_file_location( from helpers import carica_script
"book_execute", PROJECT_ROOT / "scripts/live/book_execute.py") return carica_script("scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _report_due_asset(): def _report_due_asset():
+4 -10
View File
@@ -58,11 +58,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
"""Il test che conta: conto ONLINE, posizione LEGGIBILE, ordine PRESENTE — cioe' la situazione """Il test che conta: conto ONLINE, posizione LEGGIBILE, ordine PRESENTE — cioe' la situazione
esatta del 2026-07-14 ma feed vecchio. Non deve partire nessun ordine e deve arrivare l'alert. esatta del 2026-07-14 ma feed vecchio. Non deve partire nessun ordine e deve arrivare l'alert.
""" """
import importlib.util from helpers import carica_script
spec = importlib.util.spec_from_file_location( mod = carica_script("scripts/live/book_execute.py", "book_execute_iso")
"book_execute_iso", ROOT / "scripts" / "live" / "book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
canned = dict( canned = dict(
last_data="2026-07-08", online=True, real_equity=598.0, equity=598.0, last_data="2026-07-08", online=True, real_equity=598.0, equity=598.0,
@@ -94,11 +91,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
def test_config_senza_la_chiave_non_esplode(monkeypatch, capsys): def test_config_senza_la_chiave_non_esplode(monkeypatch, capsys):
"""Una config priva di max_data_age_days deve ricadere sul default, non sollevare KeyError """Una config priva di max_data_age_days deve ricadere sul default, non sollevare KeyError
dentro il percorso con soldi veri.""" dentro il percorso con soldi veri."""
import importlib.util from helpers import carica_script
spec = importlib.util.spec_from_file_location( mod = carica_script("scripts/live/book_execute.py", "book_execute_iso2")
"book_execute_iso2", ROOT / "scripts" / "live" / "book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
canned = dict(last_data=str(pd.Timestamp.now(tz="UTC").normalize().date()), online=True, canned = dict(last_data=str(pd.Timestamp.now(tz="UTC").normalize().date()), online=True,
real_equity=598.0, equity=598.0, eq_basis="t", cap_per_asset=300.0, real_equity=598.0, equity=598.0, eq_basis="t", cap_per_asset=300.0,
skh_error=None, pos_error=None, assets=[], orders=[]) skh_error=None, pos_error=None, assets=[], orders=[])
+168
View File
@@ -0,0 +1,168 @@
"""Un flag ignoto non e' l'azione di default (2026-09-02).
Costo misurato durante una revisione dello stesso giorno: `trades_db.py --help` non stampava
l'uso, cadeva in `sync()` e riscriveva `meta.ultimo_sync`. Su `journal.py` lo stesso errore
avrebbe scritto una pagina e una riga di DB; su `analista.py` avrebbe speso una chiamata al
modello e mandato un Telegram.
Il test guarda anche l'altra meta' del contratto (P15): i flag che il CRON usa devono restare
accettati una guardia che rompe il cron e' peggio del difetto che ripara.
"""
from __future__ import annotations
import ast
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live.cli import UsoSbagliato, valida # noqa: E402
# Tutti gli script di scripts/live/ che NON usano argparse: la guardia deve coprirli tutti, e
# l'elenco si DERIVA dalla cartella (P1) — un file nuovo senza guardia fa fallire il test, non
# passa inosservato perche' nessuno ha aggiornato una lista.
LIVE = Path(__file__).resolve().parents[1] / "scripts" / "live"
def _senza_argparse() -> list[str]:
fuori = []
for f in sorted(LIVE.glob("*.py")):
s = f.read_text()
if f.name == "__init__.py" or "argparse" in s or "if __name__" not in s:
continue
fuori.append(f.name)
return fuori
SCRIPT_GUARDATI = tuple(_senza_argparse())
assert SCRIPT_GUARDATI, "nessuno script da guardare: il modulo non sta controllando niente"
SCRIVONO = ("trades_db.py", "journal.py", "analista.py") # i tre che hanno pagato il difetto
# ---------------------------------------------------------------- la funzione
def _v(argv, **kw):
return valida("x.py", "uso: x.py", argv=argv, esci=False, **kw)
def test_un_flag_noto_passa_e_torna_gli_argomenti():
assert _v(["--report"], flag=("--report",)) == ["--report"]
def test_un_flag_ignoto_e_un_errore_non_il_default():
with pytest.raises(UsoSbagliato) as e:
_v(["--help-me"], flag=("--report",))
assert "--help-me" in str(e.value) and "--report" in str(e.value) # P4: cosa, e cosa si puo'
def test_il_valore_di_un_flag_non_e_scambiato_per_un_flag():
assert _v(["--giorno", "2026-08-21"], con_valore=("--giorno",)) == ["--giorno", "2026-08-21"]
with pytest.raises(UsoSbagliato):
_v(["--giorno"], con_valore=("--giorno",)) # manca il valore
def test_un_valore_che_sembra_un_flag_resta_un_valore():
assert _v(["--nota", "--quiet"], con_valore=("--nota",), flag=("--quiet",))
def test_un_posizionale_e_ignoto():
with pytest.raises(UsoSbagliato):
_v(["report"], flag=("--report",))
def test_senza_argomenti_non_solleva():
assert _v([], flag=("--report",)) == []
def test_help_non_e_un_errore():
assert _v(["--help"], flag=("--report",)) == ["--help"]
# ---------------------------------------------------------------- gli script veri
def _uso(nome: str) -> str:
"""La costante USO dello script, letta dal sorgente senza importarlo."""
src = (ROOT / "scripts" / "live" / nome).read_text()
for n in ast.parse(src).body:
if isinstance(n, ast.Assign) and getattr(n.targets[0], "id", "") == "USO":
return n.value.value
raise AssertionError(f"{nome} non dichiara USO")
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
def test_lo_script_chiama_valida_prima_di_ogni_effetto(nome):
"""`valida` dev'essere la PRIMA istruzione eseguibile del blocco __main__: uscire dopo aver
aperto il DB o sincronizzato sarebbe uscire dopo l'effetto."""
src = (ROOT / "scripts" / "live" / nome).read_text()
main = [n for n in ast.parse(src).body
if isinstance(n, ast.If) and "__main__" in ast.dump(n.test)]
assert main, f"{nome}: nessun blocco __main__"
corpo = [n for n in main[0].body if not isinstance(n, ast.ImportFrom | ast.Import)]
chiamate = [n for n in ast.walk(corpo[0]) if isinstance(n, ast.Call)]
assert any(getattr(c.func, "id", "") == "valida" for c in chiamate), \
f"{nome}: la prima istruzione di __main__ non e' valida()"
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
def test_l_uso_elenca_esattamente_i_flag_dichiarati(nome):
"""P4 e P1: il testo d'aiuto non e' prosa libera — deve nominare i flag che lo script accetta."""
src = (ROOT / "scripts" / "live" / nome).read_text()
dichiarati = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
uso = _uso(nome)
mancanti = {f for f in dichiarati if f not in uso}
assert not mancanti, f"{nome}: flag accettati ma non documentati in USO: {mancanti}"
# I flag che il cron usa DAVVERO, letti dagli script di cron: se la guardia ne rifiutasse uno,
# il libro di bordo si fermerebbe la notte stessa (P16: il cron gira dal working tree).
def test_i_flag_del_cron_sono_tutti_accettati():
usati: dict[str, set[str]] = {}
for sh in (ROOT / "scripts").glob("cron_*.sh"):
for riga in sh.read_text().splitlines():
for nome in SCRIPT_GUARDATI:
if f"scripts/live/{nome}" in riga and not riga.lstrip().startswith("#"):
usati.setdefault(nome, set()).update(re.findall(r"(--[a-z-]+)", riga))
assert usati, "nessun cron chiama gli script guardati: il test non sta controllando niente"
for nome, flag in usati.items():
src = (ROOT / "scripts" / "live" / nome).read_text()
noti = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
assert flag <= noti, f"{nome}: il cron usa {flag - noti}, che la guardia rifiuterebbe"
# controllo positivo (M15): il confronto sa dire di no. Se il cron usasse un flag non
# dichiarato, questo test lo direbbe — qui lo si verifica su un caso finto.
assert not {"--inventato"} <= {"--quiet"}
@pytest.mark.parametrize("nome", SCRIVONO)
def test_help_esce_zero_e_non_scrive(nome):
"""Controllo end-to-end (M15): il caso che ha causato il difetto, sui tre script veri."""
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--help"],
capture_output=True, text=True, cwd=ROOT, timeout=120)
assert r.returncode == 0, r.stderr[-500:]
assert "uso:" in r.stdout
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
@pytest.mark.parametrize("nome", SCRIVONO)
def test_un_flag_ignoto_esce_due_e_non_scrive(nome):
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--nonesiste"],
capture_output=True, text=True, cwd=ROOT, timeout=120)
assert r.returncode == 2, r.stdout[-300:]
assert "non previsto" in r.stderr
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
def test_ogni_script_senza_argparse_ha_la_guardia():
"""L'elenco e' derivato dalla cartella: uno script nuovo che legge sys.argv a mano e non
chiama `valida` fa fallire questo test invece di ripetere il difetto in silenzio."""
senza = [n for n in SCRIPT_GUARDATI
if "valida(" not in (LIVE / n).read_text().split("if __name__")[1]]
assert not senza, f"script senza guardia sui flag: {senza}"
assert len(SCRIPT_GUARDATI) >= 17, f"troppo pochi script guardati: {SCRIPT_GUARDATI}"
+112
View File
@@ -568,3 +568,115 @@ def test_twr_riproduce_il_numero_del_diario_01_09(db, monkeypatch):
r = J.rendimento_twr(db, "2026-09-01T23:59:59+00:00") r = J.rendimento_twr(db, "2026-09-01T23:59:59+00:00")
assert round(100 * r["twr"], 2) == 10.80 assert round(100 * r["twr"], 2) == 10.80
assert r["certi"] == pytest.approx(1399.39) assert r["certi"] == pytest.approx(1399.39)
def test_twr_a_base_zero_e_None_anche_sul_trading(db, monkeypatch):
# revisione 02/09: a base zero `movimenti_capitale` non vede il salto 0 -> X del primo
# versamento, quindi `trading = e1-e0-certi` varrebbe l'intero conto. Tre stati, non due.
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T09:47:01+00:00", 0.0),
("2026-08-24T10:47:01+00:00", 598.06),
("2026-08-25T10:47:01+00:00", 610.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["trading"] is None and "base" in r["motivo"]
def test_twr_due_movimenti_consecutivi_non_producono_segmenti_a_lunghezza_zero(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-24T11:47:01+00:00", 2000.0), # movimento nel PRIMO intervallo
("2026-08-24T12:47:01+00:00", 3400.0), # movimento consecutivo
("2026-08-25T10:47:01+00:00", 3434.0)]) # +1% di trading
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert len(r["segmenti"]) == 1 and r["segmenti"][0]["ret"] == pytest.approx(0.01)
assert r["twr"] == pytest.approx(0.01)
assert all(s["da"] != s["a"] for s in r["segmenti"])
assert r["certi"] == pytest.approx(1400.0 + 1400.0)
def test_twr_senza_tempo_a_mercato_e_zero_non_None(db, monkeypatch):
# due letture, la seconda e' un versamento: nessun segmento, rendimento 0, dichiarato numero
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-24T11:47:01+00:00", 2000.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] == 0.0 and r["segmenti"] == [] and r["trading"] == pytest.approx(0.0)
# ------------------------------------------------- mercato NON misurabile (revisione 02/09)
# Il feed 1h certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
# `asof` restituiva la STESSA barra per le due letture, max_mkt 0, e qualunque calo >=10%
# diventava un "movimento" — un crash stampato come prelievo. E con il feed assente tutto
# era "ambiguo", certi=0, e il TWR tornava a essere e1/e0-1 sotto l'etichetta sbagliata.
def test_un_salto_oltre_la_fine_del_feed_e_ambiguo_con_la_ragione(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) # copre fino al 27/08
_serie_equity(db, [("2026-08-28T10:47:01+00:00", 2000.0),
("2026-08-28T11:47:01+00:00", 1760.0)]) # -12% dopo la fine
p = J.pnl_giorno(db, date(2026, 8, 28))
ev = p["movimenti_giorno"]
assert ev[0]["classe"] == "ambiguo" and ev[0]["mercato_max_pct"] is None
assert "fermo a 2026-08-27" in ev[0]["mercato_nota"]
assert p["mov_giorno"] == 0.0 # NON scorporato
assert p["trading_da_arming"] is None and p["twr_da_arming"] is None
assert "non classificabile" in p["twr_motivo"]
def test_con_il_feed_assente_il_twr_e_None_non_il_grezzo(db, monkeypatch):
monkeypatch.setattr(J, "_px_1h", lambda a: None)
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.0),
("2026-08-25T11:47:01+00:00", 2066.0)])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert r["twr"] is None and r["trading"] is None
assert "non leggibile" in r["motivo"]
assert r["eventi"][0]["classe"] == "ambiguo"
def test_il_giornale_e_il_report_usano_la_stessa_funzione(db, monkeypatch):
# P1, revisione 02/09: pnl_giorno non rifa' piu' `cum - certi`; prende trading e TWR da
# rendimento_twr, e la pagina lo stampa accanto al cumulato
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T10:47:01+00:00", 630.0),
("2026-08-25T11:47:01+00:00", 2030.0),
("2026-08-25T12:47:01+00:00", 2009.7)])
p = J.pnl_giorno(db, date(2026, 8, 25))
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert p["trading_da_arming"] == pytest.approx(r["trading"])
assert p["twr_da_arming"] == pytest.approx(r["twr"]) == pytest.approx(1.05 * 0.99 - 1)
md = J.rendi_markdown(db, _voce(pnl=p, giorno="2026-08-25"))
assert "TWR **+3.95%**" in md
def test_la_leva_del_classificatore_include_la_scala_e_il_tetto_di_codice(db, monkeypatch, tmp_path):
# P1: la chiave di scala del 01/09 entra nel bound da sola; e il fallback e' il tetto di CODICE
from src.live import book as B
cfg = tmp_path / "live.json"
cfg.write_text(json.dumps({"max_notional_per_asset_frac": 0.5, "book_scale_k": 1.25}))
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path); (tmp_path / "config").mkdir()
cfg.rename(tmp_path / "config" / "live.json")
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0), ("2026-08-25T11:47:01+00:00", 2000.0)])
mv = J.movimenti_capitale(db, "2026-08-25T23:59:59+00:00")
assert mv["leva_tetto"] == pytest.approx(min(0.5 * 2 * 1.25, B.LEVA_LORDA_MAX))
def test_balance_watch_registra_anche_l_indice_usde(monkeypatch):
"""Debito §5.15, primo passo (02/09): serve una serie ORARIA dell'indice usde_usdc — quella
di `usde_watch` e' giornaliera, e il classificatore confronta letture a un'ora di distanza.
Il campione la porta, e un indice non leggibile e' None con la ragione, mai 1.0 (P5)."""
from helpers import carica_script
bw = carica_script("scripts/live/balance_watch.py")
class _C:
def account_summary(self, cur): return {"balance": 1.0, "equity": 1.0}
def positions(self, cur): return []
from src.live import usde as U
monkeypatch.setattr(U, "prezzo_indice", lambda *a, **k: 0.9997)
assert bw.campiona(_C())["usde_usdc"] == 0.9997
def _rotto(*a, **k): raise RuntimeError("giu'")
monkeypatch.setattr(U, "prezzo_indice", _rotto)
rec = bw.campiona(_C())
assert rec["usde_usdc"] is None and rec["usde_usdc_errore"] == "RuntimeError"
+41 -16
View File
@@ -6,25 +6,21 @@ finisce in tmp grazie alla fixture autouse di conftest: qui non si tocca il libr
""" """
from __future__ import annotations from __future__ import annotations
import importlib.util
import sys import sys
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from helpers import carica_script # noqa: E402
from src.live import journal as J # noqa: E402 from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402 from src.live import tradesdb as T # noqa: E402
def _carica_script(): def _carica_script():
spec = importlib.util.spec_from_file_location("trades_db_script", ROOT / "scripts/live/trades_db.py") return carica_script("scripts/live/trades_db.py", "trades_db_script")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _px_piatto(livello=100.0): def _px_piatto(livello=100.0):
@@ -33,11 +29,11 @@ def _px_piatto(livello=100.0):
def _prepara(con, equity): def _prepara(con, equity):
con.execute("INSERT INTO fills (fill_id, ts_utc, ts_source, asset, side, qty, price, fee) " # il fill si scrive con `upsert_fills`, non con un INSERT a mano: una riga scritta a mano
"VALUES ('f1','2026-08-24T10:47:15+00:00','test','BTC','buy',0.001,60000.0,0.01)") # aveva `verified` NULL, una forma che la produzione non produce mai
for ts, eq in equity: T.upsert_fills(con, [T.Fill(ts_utc="2026-08-24T10:47:15+00:00", ts_source="test",
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test")) asset="BTC", side="buy", qty=0.001, price=60000.0, fee=0.01)])
con.commit() T.upsert_equity(con, [(ts, eq, "test") for ts, eq in equity])
def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys): def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys):
@@ -51,10 +47,10 @@ def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys):
con.close() con.close()
_carica_script().report() _carica_script().report()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "TWR : +3.95%" in out assert "TWR" in out and "+3.95%" in out
assert "+234.95%" not in out and "+235" not in out # il grezzo non compare piu' assert "+234.95%" not in out # il grezzo non compare piu'
assert "movimenti capitale : +1,400.00 certi" in out assert "+1,400.00 certi" in out
assert "trading da arming : +9.70" in out assert "+9.70" in out
assert "movimenti di capitale INCLUSI" in out # il delta $ resta, etichettato assert "movimenti di capitale INCLUSI" in out # il delta $ resta, etichettato
@@ -65,4 +61,33 @@ def test_il_report_senza_movimenti_dice_lo_stesso_numero_di_prima(monkeypatch, c
con.close() con.close()
_carica_script().report() _carica_script().report()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "TWR : +5.00%" in out and "movimenti capitale" not in out assert "+5.00%" in out and "movimenti capitale" not in out
def test_il_report_a_base_zero_dice_n_d_e_non_l_intero_conto(monkeypatch, capsys):
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
con = T.connect()
_prepara(con, [("2026-08-24T09:47:01+00:00", 0.0),
("2026-08-24T10:47:01+00:00", 598.06),
("2026-08-25T10:47:01+00:00", 610.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert out.count("n/d") >= 2 and "TWR" in out
assert "+610.00 di equity" in out and "arming : +610.00" not in out
def test_il_report_con_il_feed_assente_non_stampa_il_grezzo_come_TWR(monkeypatch, capsys):
# revisione 02/09: con data/raw assente (host ripristinato prima di rebuild_history) ogni
# salto era "ambiguo", certi=0, e il report stampava +243% sotto l'etichetta TWR
monkeypatch.setattr(J, "_px_1h", lambda a: None)
con = T.connect()
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0),
("2026-08-25T11:47:01+00:00", 2000.0),
("2026-08-26T10:47:01+00:00", 2020.0)])
con.close()
_carica_script().report()
out = capsys.readouterr().out
assert out.count("n/d") >= 2 and "TWR" in out
assert "% =" not in out and "+236" not in out
assert "[ambiguo: mercato non misurabile" in out