41 Commits

Author SHA1 Message Date
Adriano bebfa63213 tradesdb: fill cron_log arricchiti con ora, prezzo e pos_after dal jsonl (#40)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:51:15 +00:00
Adriano ae887a3d98 book_execute: pos_after di nuovo nel jsonl, letta subito dopo il rebalance in un try (#39)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 17:42:34 +00:00
Adriano d34b0a81d8 merge lavoro/goldrake-u11 (secondo giro 27/09) 2026-09-27 15:40:43 +00:00
Adriano cfb0e1e57f merge lavoro/goldrake-u10 (secondo giro 27/09) 2026-09-27 15:40:43 +00:00
Adriano 39adfbf267 merge lavoro/goldrake-u9 (secondo giro 27/09) 2026-09-27 15:40:42 +00:00
Adriano 81b495eb10 merge lavoro/goldrake-u8 (secondo giro 27/09) 2026-09-27 15:40:42 +00:00
Adriano 28a6d79e4f tradesdb: refilo di uno spazio in un commento (#37 C11) 2026-09-27 15:26:43 +00:00
Adriano ac6ad7f116 revisione: tetto per-voce dedicato per CLAUDE.md (150k, il generico restava a 90k con margine minimo) e avviso quando una voce supera il 95% del suo tetto (#37 C12) 2026-09-27 15:24:36 +00:00
Adriano ddcd3773a7 cron_book/cron_cuscino: flock -n sul conto, un giro occupato si salta e si scrive (#36)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:19:49 +00:00
Adriano 213dd12bab docstring: execution/deribit/book allineati al codice attuale, niente importi cablati (#35 A9)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:19:37 +00:00
Adriano 3af6e1f0c3 telegram_daily: gate da monitor_health (non cablati), formato giorni a tre casi, header condizionale su flat (#35 C14)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:18:25 +00:00
Adriano 829ebc7f98 book: min_order_usd da config nel report (#35 A7)
book_report costruiva l'ordine con min_usd=5.0 fisso invece di leggere
min_order_usd da config/live.json, come fa gia' scripts/live/book_execute.py
(default 5.0 se assente). Oggi config = 5, nessun cambio di comportamento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:17:42 +00:00
Adriano 52a905d497 book: leva_tetto unica per cuscino USDE e giornale (#28)
La leva lorda massima del libro era ridichiarata con tre formule diverse:
usde.frazione_cuscino (N_ASSET fisso, senza scala), la regola `leva` del
giornale (senza scala) e journal.movimenti_capitale (con scala). A scala
1,25 il cuscino USDC risultava 0,30 dell'equity invece di 0,375.

Nuova src/live/book.leva_tetto(cfg) = len(ASSETS) x min(WEIGHT, frac) x
scala, al piu' LEVA_LORDA_MAX; su ScalaNonAutorizzata non solleva, ritorna
il tetto di codice (cuscino_watch non deve morire sulla config sbagliata).
usde.frazione_cuscino e la regola `leva` del giornale ora la derivano;
movimenti_capitale idem, nel suo try/except esistente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:17:01 +00:00
Adriano 12291709ed revisione, analista: taglio Telegram sul testo grezzo, prima di escape/markup (#35 C13)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:15:22 +00:00
Adriano 8e75e9c9a7 sleeves+edge_watch: pesi derivati da book (import locale), titolo kill corretto (#35 B12)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:15:07 +00:00
Adriano 13c136cf4b tradesdb: docstring corretto (jsonl ha l'ora vera dal 24/08, non piu' la barra); trades_db sync confronta i conteggi jsonl/DB e notifica una volta per episodio (#37 C11, #35 A9) 2026-09-27 15:14:04 +00:00
Adriano e82f2ee163 venue_probe: atteso hardcoded False in diagnose, l'unico chiamante non cade mai nello slot (#35 B11)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:29 +00:00
Adriano d80323ff59 analista: testata mostra trading_giorno (non l'equity grezza) e 'nel giorno', non 'oggi' (#35 C8)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:20 +00:00
Adriano d59aea7a0a monitor_health: giro orario dei sorveglianti da 3h con marcatore gia' detto (#30)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:10 +00:00
Adriano 31ec767ff5 telegram_daily: soglia di accensione generale su k flip, non il minimo fisso (#35 C3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:10:04 +00:00
Adriano 95cfb396e7 fee_watch: realized_fee_bps sempre calcolata, divergenza da tier notificata una volta per episodio (#37 B9) 2026-09-27 15:06:40 +00:00
Adriano 604fde689e cuscino_watch: riconverti distingue parziale/fallita rileggendo la quota (#35 B10)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:03:02 +00:00
Adriano 841d4c78cd book_execute: integrazione U1+U2 — un solo Telegram in solo protezione, stato unknown, set_open_cap diretto (#18, #21, #22, #24)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:14:28 +00:00
Adriano 0e6cf17081 merge lavoro/goldrake-u4 (revisione 27/09) 2026-09-27 13:53:10 +00:00
Adriano dab8a88e1c merge lavoro/goldrake-u3 (revisione 27/09) 2026-09-27 13:53:10 +00:00
Adriano ed4f8fd39b merge lavoro/goldrake-u2 (revisione 27/09) 2026-09-27 13:53:10 +00:00
Adriano 8405fe2a7d merge lavoro/goldrake-u1 (revisione 27/09) 2026-09-27 13:53:10 +00:00
Adriano 7234625863 usde_convert: residui cercati sotto USDC e USDE (#26)
Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: la
quotazione (USDC) o la base (USDE). cancella_residui interroga entrambe e
unisce per order_id prima di applicare il filtro instrument+label, cosi' un
residuo elencato sotto USDE non si accumula in eterno per non essere mai
letto. Se una delle due letture solleva, solleva come prima (nessun try
aggiunto attorno al giro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:45:04 +00:00
Adriano 9ce924d97c book_execute: chiama set_open_cap (U1) se il trader lo espone, prima di rebalance_signed
hasattr perche' U1 e U2 si uniscono dopo questo rilascio: DeribitTrader oggi non ha
set_open_cap, e l'hasattr evita che l'unione dei due rami rompa in un senso o nell'altro.
Si toglie l'hasattr al merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:41:07 +00:00
Adriano 7c72308852 live/deribit,execution: open_orders non ingoia gli errori; ensure_disaster_sl -> unknown (#24)
open_orders solleva RuntimeError se 'all' o 'trigger_all' falliscono, invece di ritornare []
(un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione persa su
'all'). ensure_disaster_sl avvolge la lettura di posizione+open_orders in try/except: su
eccezione ritorna {"state": "unknown", "notes": ...} senza cancellare ne' piazzare, distinto
da "naked" (protezione tolta e non rimessa). shadow.shadow_report chiama open_orders gia'
dentro try/except (non toccato).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:40:50 +00:00
Adriano 65a7f0d8fc book_execute: SOLO PROTEZIONE su equity/SKH ignoti; fill mai persi su rebalance parziale (#21, #22, #23)
Prima, con equity reale non leggibile il giro dimensionava comunque un ordine sul paper_cap
($2.000 nominali) e lo mandava; con SKH ignoto il target restava quello (fail-safe solo nel
segno). Ora: se non si sa quanto vale il conto o qual e' il segnale, nessun rebalance_signed —
ma il disaster-SL si controlla LO STESSO per ogni asset (dipende da posizione/mark, non da
equity/segnale). Giro degradato -> exit 2, solo se armato (non in anteprima dry-run).

Fill persi: rebalance_signed puo' eseguire un fill (es. il close di un flip) e poi sollevare nel
passo successivo (l'open dall'altra parte) — quel fill spariva perche' la lettura di `newpos`,
PRIMA del giro che lo registrava, andava in eccezione a sua volta. Estratta la registrazione in
_registra_fill(...), chiamata SUBITO dopo ogni fill (anche quelli di RebalanceParziale, nuova
eccezione di execution.py con .fills/.causa) e PRIMA di leggere la posizione; la lettura ora e'
in un try separato, non fatale (pos_after=None + "reconcile: pos illeggibile" se fallisce).

Aggiornati due test che assumevano il comportamento vecchio (eq_fallback "prosegue" sul paper
capital) e il guard sulla sorgente di test_tradesdb.py (bar_ts ora e' un parametro di
_registra_fill, non piu' `r['last_data']` inline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:40:15 +00:00
Adriano f79fb54221 live/execution: tetto di apertura per strumento da config, non solo MAX_AMOUNT fisso (#18)
DeribitTrader.set_open_cap(instrument, usd_cap, mark) deriva amount_max = 1,10*usd_cap/mark
dal cap di config del libro; _submit lo usa quando presente, MAX_AMOUNT resta il DEFAULT per
chi non lo chiama (il micro-test). Firma per chi la richiama prima di rebalance_signed (U2):
trader.set_open_cap(inst, r["cap_per_asset"] * r["scala"], mark).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:39:54 +00:00
Adriano 4725839041 usde_watch: notify affidabile (allertati) e quota derivata, non piu' rinviata (#29 B2, #35 B3/B4)
#29 B2: lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si
leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema
di cuscino_watch.py: nuove_condizioni()/rec["allertati"] contano una
condizione come "gia' detta" solo se l'invio precedente e' RIUSCITO; un
record vecchio senza `allertati` vale come "tutto allertato" (nessun rispam
retroattivo). Lo stato si scrive una volta sola, a fine giro.

#35 B3/B4: la quota non e' piu' una decisione dell'operatore rinviata a
lunedi' — dal 10/09 (issue #7) e' DERIVATA dal cuscino USDC
(usde._nota_cuscino) — quindi tolti DECISIONE_QUOTA_DAL, quota_da_decidere()
e la notify "la decisione di QUOTA e' dovuta". Il tetto di allerta IDONEO
legge c["quota_max_frac"] invece del 50% cablato. La riga "margine" non dice
piu' "conto SEGREGATO" (il modello e' Cross X:SM dal 31/08): stampa il totale
USDC + USDE scontato del cross, coi numeri che la funzione ha gia'.

Le due modifiche toccano lo stesso blocco (allarmi + STATE write + stampa) e
non si possono separare per hunk senza rischio: un solo commit per il file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:46 +00:00
Adriano 5acc31e390 venue_watch: BLIND ha una severity propria, il titolo non chiede piu' il prelievo (#31)
Con un asset BLIND e nessun ALERT, severity restava None e il titolo del
runner cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per
un episodio che e' "non vedo", non "stress misurato". run_once ora assegna
severity="blind" quando c'e' un BLIND senza ALERT (ALERT resta piu' forte, P9);
_titolo distingue il caso con "venue_watch CIECO — referenze non leggibili,
nessun giudizio sul venue". La ripetizione oraria di BLIND non e' toccata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:35 +00:00
Adriano dda5d41f38 fee_watch: save_state solo se l'invio necessario e' riuscito (#29 B8)
save_state girava sempre dopo l'eventuale notify, anche quando l'invio
falliva: il giro dopo diff_vs confrontava lo stato nuovo con se stesso e il
cambiamento (o il livello AZIONE) spariva senza essere mai stato detto. Ora
lo stato si salva solo se non serviva un invio, o se l'invio e' riuscito.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:28 +00:00
Adriano f0193f013d usde_convert: cancella i residui GTC delle proprie label, nel ciclo e all'avvio (#26)
Il gateway cerbero-mcp non accetta time_in_force (PlaceOrderReq non ha il
campo): un limit riempito solo in parte restava APERTO per sempre e si
accumulava a ogni giro. cancella_residui() pulisce lo spot USDE_USDC dai
residui con label "usde-quota-*" prima del ciclo; nel ciclo, un ordine con
filled < amt viene cancellato subito dopo, e una cancellazione fallita ferma
il ciclo (mai un secondo ordine con un residuo vivo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:20 +00:00
Adriano 0c19c804d1 analista: --secco non scrive piu' nel DB (#34)
scrivi() chiamava J.salva() prima del controllo su --secco: un giro "a secco" scriveva
comunque le sezioni misurate nel giornale. Ora salva() (e scrivi_file, che era gia' dopo
il return) girano solo quando non e' secco; rendi_markdown() tollera l'assenza della riga
in DB (nota/analisi vuote, verificato).

Test nuovo in tests/test_analista.py (non c'e' un test_analista_u4.py: l'analisi indicava
i due file journal/tradesdb, questo fix e' su analista.py e segue la convenzione un
file-di-test-per-modulo del resto della suite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:10 +00:00
Adriano 2bf4f9cceb live/deribit: position_usd legge il segno da direction, non dal solo size (#17)
Il gateway manda size/size_currency in assoluto e il segno a parte in direction:
letta com'era, uno short risultava un long e close()/ensure_disaster_sl decidevano
lato ordine e prezzo dello stop dal segno sbagliato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:37:36 +00:00
Adriano b8c2bba320 tradesdb: lotti con segno (short) e fill a qty<=0 fuori dal FIFO (#32 C2, C10)
C2: fifo_roundtrips ora apre un lotto SHORT quando un sell non trova lotti long da chiudere
(prima il resto spariva in silenzio), chiude prima il lato opposto e apre l'eccedenza nel
segno del fill — anche a meta' dello stesso fill (inversione long<->short). stato_aperto e
report() portano la direzione del lotto residuo.
C10: un fill "NON VERIFICATO" a qty 0 (place_order error) non entra piu' nel FIFO ne' in
stato_aperto (WHERE qty > 0 in rebuild_roundtrips/stato_aperto); resta nel DB marcato
(verified=0), non e' scomparso.

test_fifo_non_mescola_gli_asset aggiornato: una vendita ETH senza lotti long ora apre uno
short invece di sparire (era il comportamento che C2 corregge). Test nuovi in
tests/test_tradesdb_u4.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:37:20 +00:00
Adriano ac21e1c97d journal: dichiarati nello stesso intervallo si sommano, giro corrente, finestra 7gg, DD netto (#27, #35)
#27: due movimenti dichiarati nello stesso intervallo si sommavano al posto di sovrascriversi
(l'aggancio "dichiarato+rilevato" ora cerca solo fonte=="rilevato").
#35 C9: salute() non conta piu' il giro dell'ora corrente prima che scatti (minuto :47 +
margine, MINUTO_GIRO costante di modulo).
#35 C15: la finestra "ultimi 7 giorni" della concentrazione ora copre 7 giorni di calendario,
non 8 (baseline = ultima lettura al o prima di giorno-7 fine giornata).
#35 C7: il drawdown si calcola sulla serie equity al netto dei movimenti di capitale certi,
cosi' un prelievo non si legge come perdita di trading e un versamento non alza il picco.

Test nuovi in tests/test_journal_u4.py; tests/test_journal.py aggiornato (l'orologio del
test su salute() era reale e non deterministico, ora e' fissato).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:35:08 +00:00
Adriano cfc42653cc shadow: USDE illeggibile -> equity IGNOTA non 0; online richiede tutti i mark mainnet (#20, #25)
Un'eccezione sulla lettura del conto USDE valeva 0 USDE: se il conto ne aveva davvero, l'equity
totale risultava sottostimata senza che nulla lo dicesse. Ora _collaterale_usde ritorna None +
la nota dell'eccezione, e _equity propaga l'ignoto (real_eq None -> eq_fallback, non un numero
falso). shadow_report.online guardava solo il mark di BTC: con ETH in fallback il giro si
considerava comunque online. Ora richiede TUTTI i mark da mainnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:33:48 +00:00
47 changed files with 3822 additions and 370 deletions
+8 -2
View File
@@ -35,10 +35,16 @@ mkdir -p logs
# partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2 # partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2
# = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py. # = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py.
uv run python scripts/live/venue_watch.py --quiet || true uv run python scripts/live/venue_watch.py --quiet || true
uv run python scripts/live/book_execute.py --execute # LOCK SUL CONTO (#36): cuscino_watch (cron_cuscino.sh, :53) puo' mandare ordini spot USDE
# sullo stesso conto — separati solo dal tempo, giri fino a 659s misurati. `flock -n` (mai
# `-w`, un SIGTERM a meta' ordine perde la riga di log): se cuscino_watch tiene il lock questo
# giro si SALTA (rc 75) invece di sovrapporsi. Niente kill/timeout sul comando.
flock -n -E 75 logs/.conto.lock uv run python scripts/live/book_execute.py --execute; rc=$?
[ $rc -eq 75 ] && echo " book: SALTATO — conto occupato da un altro giro (logs/.conto.lock)"
# Libro di bordo: materializza in data/live/trades.db (che il backup COPRE) l'ora vera dei # Libro di bordo: materializza in data/live/trades.db (che il backup COPRE) l'ora vera dei
# fill, che altrimenti vive solo in questo log — gitignored, fuori dal backup e ruotabile. # fill, che altrimenti vive solo in questo log — gitignored, fuori dal backup e ruotabile.
# Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte. # Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte. Fuori dal lock:
# e' sola lettura, non tocca il conto.
uv run python scripts/live/trades_db.py --sync --quiet || true uv run python scripts/live/trades_db.py --sync --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') =====" echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_book.log 2>&1 } >> logs/cron_book.log 2>&1
+9 -1
View File
@@ -5,11 +5,19 @@
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le # PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo # guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
# stesso del libro (execution_enabled in config/live.json). # stesso del libro (execution_enabled in config/live.json).
# LOCK SUL CONTO (#36): cuscino_watch e book_execute (cron_book.sh, :47) sono separati solo dal
# tempo e possono mandare ordini sullo stesso conto — giri fino a 659s sono stati misurati.
# `flock -n` (mai `-w`: un'attesa farebbe scivolare il giro orario) su logs/.conto.lock: se
# book_execute lo tiene, questo giro si SALTA (rc 75) invece di sovrapporsi.
export PATH="/home/adriano/.local/bin:$PATH" export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1 cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs mkdir -p logs
{ {
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino =====" echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
uv run python scripts/live/cuscino_watch.py --quiet || true flock -n -E 75 logs/.conto.lock uv run python scripts/live/cuscino_watch.py --quiet; rc=$?
[ $rc -eq 75 ] && echo " cuscino: SALTATO — conto occupato (logs/.conto.lock)"
# (#30) giro orario dei sorveglianti a max_age<=6h: notifica solo il NUOVO, marcatore in
# data/live/monitor_health_detto.json. Fuori dal lock: e' sola lettura, non tocca il conto.
uv run python scripts/live/monitor_health.py --quiet --solo-orari || true
echo "===== done $(date -u '+%H:%M:%SZ') =====" echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_cuscino.log 2>&1 } >> logs/cron_cuscino.log 2>&1
+2 -1
View File
@@ -73,7 +73,8 @@ def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str],
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True, def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
telegram: bool = True) -> str: telegram: bool = True) -> str:
voce = J.costruisci(con, g) voce = J.costruisci(con, g)
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge if not secco: # --secco: stampa e NON salva (#34) — niente scrittura
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
pagina = J.rendi_markdown(con, voce) pagina = J.rendi_markdown(con, voce)
st = storico(con, g) st = storico(con, g)
prompt = A.costruisci_prompt(pagina, st) prompt = A.costruisci_prompt(pagina, st)
+110 -28
View File
@@ -46,7 +46,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.live.book import ScalaNonAutorizzata, book_report from src.live.book import ScalaNonAutorizzata, book_report
from src.live.execution import DeribitTrader from src.live.execution import DeribitTrader, RebalanceParziale
from src.live.notifier import notify from src.live.notifier import notify
from src.live.venue_probe import diagnose, errori_dal_report from src.live.venue_probe import diagnose, errori_dal_report
@@ -85,6 +85,33 @@ def log_event(rec: dict):
f.write(json.dumps(rec) + "\n") f.write(json.dumps(rec) + "\n")
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int,
pos_after: float | None = None):
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la
posizione risultante: un ordine eseguito non deve dipendere dalla lettura della posizione
successiva per essere salvato (#23 — un 502 su `position_usd()` dopo un fill riuscito lo
faceva sparire dal log, perche' la lettura veniva PRIMA del giro `for f in fills`).
`pos_after` arriva dal chiamante (#39 — regressione di #23): nel ramo normale di `_run` si
legge SUBITO dopo il rebalance, in un try che isola la lettura dalla registrazione dei fill;
resta None quando la lettura solleva o quando il passo e' un `RebalanceParziale` (a meta')."""
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
verified=f.verified, notes=f.notes, net_target=net, pos_after=pos_after,
tp_frac=tp_frac, skh_sign=skh_sign))
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
fee=round(f.fee_usdc, 5), net=round(net, 0))
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
det if f.verified else {**det, "notes": f.notes})
def _run(): def _run():
cfg = load_config() cfg = load_config()
want_execute = "--execute" in sys.argv[1:] want_execute = "--execute" in sys.argv[1:]
@@ -129,8 +156,10 @@ def _run():
print(f" ultima barra : {r['last_data']}\n") print(f" ultima barra : {r['last_data']}\n")
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}") # (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]}) # quel segnale ignoto (modo SOLO PROTEZIONE, sotto, che porta anche questo errore su
# Telegram — qui solo il print, per non mandare due notify per lo stesso guasto).
print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}")
# --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) -------------------------- # --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) --------------------------
# `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e # `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e
@@ -220,10 +249,27 @@ def _run():
"azione": "rebuild_history.py --asset BTC ETH"}) "azione": "rebuild_history.py --asset BTC ETH"})
return return
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}") # (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside # questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]}) print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}")
# --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------
# Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda
# ordini di ribilancio (dimensionare un ordine su un numero che non si conosce e' peggio che
# non ordinare) -> ma CONTROLLA LO STESSO il disaster-SL di ogni asset: la protezione dipende
# da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia'
# bloccando l'esecuzione, qui senza saltare la protezione.
solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error")
# Calcolato una volta sola: serve sia nell'azione per-asset del ciclo sotto sia nel
# riepilogo finale (#21, #22) — un solo motivo, non due troncamenti che potrebbero divergere.
motivo_breve = (solo_protezione if solo_protezione and len(solo_protezione) <= 60
else (solo_protezione[:60] + "…" if solo_protezione else None))
if solo_protezione and do_execute:
titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione"
if r.get("eq_fallback") else
"⚠️ BOOK LIVE — SKH non calcolato: nessun ordine, solo protezione")
notify(titolo, {"nota": solo_protezione})
# Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento # Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento
# NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il # NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il
@@ -250,13 +296,18 @@ def _run():
# non deve togliere la rete di sicurezza all'altro. # non deve togliere la rete di sicurezza all'altro.
falliti: list[str] = [] falliti: list[str] = []
scoperti: list[str] = [] scoperti: list[str] = []
non_verificati: list[str] = []
for a in r["assets"]: for a in r["assets"]:
asset, inst = a["asset"], a["instrument"] asset, inst = a["asset"], a["instrument"]
net, cur, mark = a["net_target"], a["position_usd"], a["mark"] net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
sk = a["skh_state"] sk = a["skh_state"]
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}" sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
order = a["order"] order = a["order"]
if order is None: if solo_protezione:
# (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e'
# "non tocco perche' non so quanto vale il conto / qual e' il segnale".
act = f"HOLD (solo protezione: {motivo_breve})"
elif order is None:
act = "HOLD (a target)" act = "HOLD (a target)"
elif order.get("is_close"): elif order.get("is_close"):
act = f"CLOSE ${cur:,.0f}" act = f"CLOSE ${cur:,.0f}"
@@ -268,26 +319,37 @@ def _run():
f"| pos ${cur:+,.0f} -> {act}") f"| pos ${cur:+,.0f} -> {act}")
try: try:
if do_execute and order is not None: if do_execute and order is not None and not solo_protezione:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order) # (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
newpos = trader.position_usd(inst) # o mark sono <=0 solleva ValueError: e' giusto, finisce nell'isolamento per asset
# (except sotto), non deve fermare il giro intero.
trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark)
try:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
except RebalanceParziale as rp:
# (#23) almeno un fill e' passato prima che il resto del passo sollevasse
# (es. flip: close ok, open KO) -> si registra quel fill SUBITO (non e' un
# fill perso), poi si rilancia la causa nell'handler di isolamento esistente.
for f in rp.fills:
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
raise rp.causa
# newpos si legge SUBITO dopo il rebalance, in un try che ISOLA la lettura dalla
# registrazione dei fill (#39, regressione di #23: dal merge di #23 pos_after era
# sempre None, perche' la lettura restava un side-effect informativo dopo il ciclo
# e il suo risultato non arrivava piu' a `_registra_fill`). Se solleva, i fill si
# registrano comunque, con pos_after=None — il vincolo #23 (un fill non si perde
# per un reconcile mancato) resta: il try isola solo la LETTURA, non la scrittura.
try:
newpos = trader.position_usd(inst)
pos_after = newpos
print(f" reconcile: pos ${newpos:,.0f}")
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
pos_after = None
print(f" reconcile: pos illeggibile ({type(e).__name__})")
for f in fills: for f in fills:
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} " _registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})") tp_frac=a["tp_frac"], skh_sign=a["skh_sign"], pos_after=pos_after)
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
det if f.verified else {**det, "notes": f.notes})
print(f" reconcile: pos ${newpos:,.0f}")
if do_execute: if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short) ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
stato = ds.get("state") stato = ds.get("state")
@@ -308,6 +370,15 @@ def _run():
"amount": round(ds.get("amount") or 0, 4), "amount": round(ds.get("amount") or 0, 4),
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione", "azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
"notes": str(ds.get("notes"))[:300]}) "notes": str(ds.get("notes"))[:300]})
elif stato == "unknown":
# (#24) posizione o open_orders illeggibili: NON e' "naked" (non si e' tolto
# nulla, ensure_disaster_sl non cancella ne' piazza su dati a meta'). E' un
# giro degradato lo stesso: non si sa se la protezione esistente e' ancora
# coerente con la posizione attuale.
non_verificati.append(asset)
print(f" ⚠️ {asset}: protezione NON VERIFICATA — {ds.get('notes')}")
notify("⚠️ BOOK — disaster-SL non verificabile (lettura posizione/ordini fallita)",
{"asset": asset, "notes": str(ds.get("notes"))[:300]})
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"]) d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
falliti.append(asset) falliti.append(asset)
@@ -327,16 +398,27 @@ def _run():
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}") print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
if falliti: if falliti:
print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}") print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}")
if non_verificati:
print(f" ⚠️ disaster-SL NON VERIFICATO (lettura posizione/ordini fallita): {', '.join(non_verificati)}")
if not do_execute: if not do_execute:
print(" => DRY-RUN: nessun ordine inviato." + print(" => DRY-RUN: nessun ordine inviato." +
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute.")) ("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
elif solo_protezione:
# (#21, #22) non e' "Nessuna azione: conto gia' al target" — il conto potrebbe NON esserci
# per niente al target, semplicemente non lo si sa. Distinto dal ramo HOLD sotto.
print(f" => SOLO PROTEZIONE: nessun ordine di ribilancio ({motivo_breve}), disaster-SL controllato.")
elif all(x.startswith("HOLD") for x in actions): elif all(x.startswith("HOLD") for x in actions):
print(" => Nessuna azione: conto gia' al target netto del book.") print(" => Nessuna azione: conto gia' al target netto del book.")
else: else:
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).") print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza # Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`). # rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
if scoperti or falliti: # (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha
# ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se
# il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima.
# (#24) `non_verificati` e' degradato allo stesso modo: la protezione esistente non e' stata
# controllata, indipendentemente da solo_protezione.
if scoperti or falliti or non_verificati or (solo_protezione and do_execute):
sys.exit(2) sys.exit(2)
+61 -12
View File
@@ -80,7 +80,9 @@ CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️). # dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️).
RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore
RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min TIMEOUT_CONVERT_S = 900 # usde_convert: 12s di pausa fra ordini + le GET per giro (~14s/giro
# coi 12s di pausa); 60 ordini ~= 840s: il tetto e' stretto sul caso
# massimo, non largo come "60 ordini = 12 min" lasciava credere (#35 B10)
def leggi(path: Path = STATE) -> list[dict]: def leggi(path: Path = STATE) -> list[dict]:
@@ -158,17 +160,56 @@ def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: b
return True, "" return True, ""
def _leggi_quota_usde() -> float | None:
"""Quota USDE sul totale, STESSA formula di usde_watch.py:275 (usd/(usd+eq_usdc)), stessi
mattoni di main() qui sotto (_safe_client, account_summary, U.prezzo, U.valuta): nessuna
lettura nuova. None se il conto non e' leggibile — chi chiama tratta None come "non lo so",
non come zero (#35 B10)."""
client = _safe_client()
if client is None:
return None
try:
eq_usdc = float(client.account_summary("USDC")["equity"])
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
except Exception:
return None
px, _ = U.prezzo(client)
usd, _ = U.valuta(eq_usde, px)
try:
return round(usd / (usd + eq_usdc), 4)
except ZeroDivisionError:
return None
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict: def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).""" """Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).
Rilegge la quota USDE prima e dopo il subprocess, ANCHE su timeout o eccezione: un rc!=0 con
la quota mossa e' una conversione PARZIALE (fill gia' avvenuti prima del kill a timeout_s),
non "FALLITA" come diceva `ok = rc==0` (#35 B10). `esito` in "ok" | "parziale" | "fallita"* —
"ok" solo per compatibilita' resta anche in `ok` (bool)."""
quota_prima = _leggi_quota_usde()
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"] cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
try: try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT)) r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
rc = r.returncode
coda = (r.stdout + r.stderr).strip().splitlines()[-12:] coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:])) rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"]
except Exception as e: # noqa: BLE001 — si registra, non si ingoia except Exception as e: # noqa: BLE001 — si registra, non si ingoia
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:])) rc, coda = None, [f"{type(e).__name__}: {e}"]
quota_dopo = _leggi_quota_usde()
if rc == 0:
esito = "ok"
elif quota_dopo is None:
esito = "fallita (quota dopo non leggibile)"
else:
mossa = quota_prima is not None and abs(quota_dopo - quota_prima) > 0.005
esito = "parziale" if mossa else "fallita"
return dict(rc=rc, ok=esito == "ok", esito=esito, quota_prima=quota_prima, quota_dopo=quota_dopo,
coda=coda, cmd=" ".join(cmd[1:]))
def _safe_client(): def _safe_client():
@@ -239,17 +280,23 @@ def main() -> int:
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})") motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
else: else:
esito = riconverti(quota) ris = riconverti(quota)
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito) rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris)
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio --- # --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
# ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al # ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al
# primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'. # primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'.
def _esito_azione(r: dict) -> str: def _esito_azione(r: dict) -> str:
if r.get("tentata"): if not r.get("tentata"):
return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA") return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
+ f" (quota bersaglio {r['quota']:.0%})") esito = r.get("esito", "ok" if r.get("ok") else "fallita")
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}" if esito == "parziale":
qp, qd = r.get("quota_prima"), r.get("quota_dopo")
qp_s = f"{qp:.0%}" if qp is not None else "n/d"
qd_s = f"{qd:.0%}" if qd is not None else "n/d"
return f"{r['verso']} PARZIALE: quota da {qp_s} a {qd_s}"
return (f"{r['verso']} " + ("ESEGUITA" if esito == "ok" else "FALLITA")
+ f" (quota bersaglio {r['quota']:.0%})")
if transizione(prev, stato): if transizione(prev, stato):
if secco: if secco:
@@ -275,7 +322,9 @@ def main() -> int:
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco: elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
# non e' una transizione ma e' un ordine mandato: si dice sempre # non e' una transizione ma e' un ordine mandato: si dice sempre
r = rec["riconversione"] r = rec["riconversione"]
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"), esito = r.get("esito", "ok" if r.get("ok") else "fallita")
verbo = {"ok": "eseguita", "parziale": "PARZIALE"}.get(esito, "FALLITA")
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} {verbo}",
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])}) {"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
if rec["allertato"] is False and not secco: if rec["allertato"] is False and not secco:
from src.live.notifier import ultimo_errore from src.live.notifier import ultimo_errore
+1 -1
View File
@@ -140,7 +140,7 @@ def main() -> int:
print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · " print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · "
f"fallimenti consecutivi in corso: {r['consec_fail']}") f"fallimenti consecutivi in corso: {r['consec_fail']}")
if r["alerts"]: if r["alerts"]:
notify("🚨 EDGE WATCH — criterio di kill scattato sul book LIVE", notify("🚨 EDGE WATCH — criterio di kill scattato sul book (serie backtest dei sleeve, non il P&L eseguito)",
{f"alert {i+1}": a for i, a in enumerate(r["alerts"])}) {f"alert {i+1}": a for i, a in enumerate(r["alerts"])})
for a in r["alerts"]: for a in r["alerts"]:
print(f" ALERT: {a}") print(f" ALERT: {a}")
+56 -8
View File
@@ -79,6 +79,7 @@ SOGLIA_OK = 5.0 # <= -> nessuna azione
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01 SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
D_CAGR_PER_BPS = -0.0023 D_CAGR_PER_BPS = -0.0023
SOGLIA_DIVERGENZA_BPS = 1.0 # #37 B9: |fee realizzata - tier pubblico| oltre questo -> notify
def fetch_fees(instrument: str) -> dict: def fetch_fees(instrument: str) -> dict:
@@ -209,14 +210,30 @@ def run() -> dict:
changes = diff_vs(prev, cur) changes = diff_vs(prev, cur)
worst = max(c["taker_bps"] for c in cur.values()) worst = max(c["taker_bps"] for c in cur.values())
lvl, why = verdict(worst) lvl, why = verdict(worst)
# issue #37 (B9). realized_fee_bps() girava SOLO dentro il blocco di stampa: in cron
# (--quiet, il caso normale) non girava mai e una fee realmente pagata diversa dal tier
# pubblico non avvisava nessuno. Qui si calcola SEMPRE ed entra nel record del giro
# (registra_giro, sotto). {} -> None: "non misurata" (nessun trade recente), mai "zero".
real = realized_fee_bps() or None
divergenze = []
if real:
for ins, bps in real.items():
tier = cur.get(ins, {}).get("taker_bps")
if tier is None:
continue
d = bps - tier
if abs(d) > SOGLIA_DIVERGENZA_BPS:
divergenze.append(
f"{ins}: realizzata {bps:.2f} bps vs tier pubblico {tier:.2f} ({d:+.2f})")
return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why, return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why,
first_read=not prev) first_read=not prev, realized=real, divergenze=divergenze,
divergenza_gia_detta=bool(prev.get("divergenza_detta")))
def main() -> int: def main() -> int:
r = run() r = run()
quiet = "--quiet" in sys.argv quiet = "--quiet" in sys.argv
interessante = bool(r["changes"]) or r["level"] != "OK" interessante = bool(r["changes"]) or r["level"] != "OK" or bool(r.get("divergenze"))
if not quiet or interessante: if not quiet or interessante:
print("=" * 86) print("=" * 86)
@@ -232,7 +249,7 @@ def main() -> int:
print(" (prima lettura: stato inizializzato, nessun confronto possibile)") print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
for c in r["changes"]: for c in r["changes"]:
print(f" CAMBIO: {c}") print(f" CAMBIO: {c}")
real = realized_fee_bps() real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9)
if real: if real:
print("\n cross-check sui trade REALI del conto (fonte autorevole):") print("\n cross-check sui trade REALI del conto (fonte autorevole):")
for ins, bps in real.items(): for ins, bps in real.items():
@@ -240,18 +257,49 @@ def main() -> int:
nota = "" nota = ""
if tier is not None: if tier is not None:
d = bps - tier d = bps - tier
nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "") nota = (f" (tier taker {tier:.2f} → {d:+.2f})"
+ (" ⚠️ DIVERGE" if abs(d) > SOGLIA_DIVERGENZA_BPS else ""))
print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}") print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}")
else: else:
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)") print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
if r["changes"] or r["level"] == "AZIONE": # issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
notify("💸 FEE WATCH — schema fee Deribit", { # il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
inviato = True
if serve_invio:
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
"verdetto": f"[{r['level']}] {r['reason']}", "verdetto": f"[{r['level']}] {r['reason']}",
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])}, **{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
}) })
save_state(r["current"]) if not inviato and (not quiet or interessante):
registra_giro(RUNS, level=r["level"]) print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
# issue #37 (B9): fee realmente pagata che diverge dal tier pubblico atteso -> notify UNA
# volta per episodio. Marcatore "divergenza_detta" nello stesso state.json (stessa regola di
# B8: si scrive solo se l'invio e' riuscito), azzerato quando la divergenza torna sotto
# soglia. E' un evento indipendente dal cambio di tier: se qui serve un invio e quello di B8
# e' appena fallito, save_state sotto non gira (stessa gate di B8) e il marcatore non si
# scrive — al giro dopo si ridice, ridondante ma mai persa, la stessa scelta di B8.
divergenze = r.get("divergenze") or []
gia_detta = bool(r.get("divergenza_gia_detta"))
marcatore = gia_detta
if divergenze and not gia_detta:
inviata_div = notify("💸 FEE WATCH — fee realizzata diverge dal tier pubblico", {
f"divergenza {i+1}": d for i, d in enumerate(divergenze)
})
if inviata_div:
marcatore = True
elif not quiet or interessante:
print(" ⚠️ invio divergenza FALLITO — marcatore non scritto: il prossimo giro ritenta")
elif not divergenze:
marcatore = False
stato_da_salvare = dict(r["current"])
stato_da_salvare["divergenza_detta"] = marcatore
if not serve_invio or inviato:
save_state(stato_da_salvare)
registra_giro(RUNS, level=r["level"], realized=r.get("realized"))
return 2 if r["level"] == "AZIONE" else 0 return 2 if r["level"] == "AZIONE" else 0
+76 -3
View File
@@ -10,20 +10,91 @@ monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSP
uv run python scripts/live/monitor_health.py # report uv run python scripts/live/monitor_health.py # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
# max_age<=6h (cuscino_watch, venue_watch, collect_chain), notifica solo il NUOVO
# rispetto a data/live/monitor_health_detto.json — pensato per girare ogni ora
# (cron_cuscino.sh), non per sostituire il giro giornaliero completo.
""" """
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402 from src.live.monitor_health import ( # noqa: E402
MIN_COVERAGE,
alert_nuovi,
alerts_from,
check_all,
marcatore_aggiornato,
monitors_orari,
)
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
# (#30) marcatore "gia' detto" del giro orario: {"detti": [<chiave alert>], "ts": ...}. Vive fuori
# da src/ perche' e' I/O, non parte della misura pura.
DETTO = ROOT / "data" / "live" / "monitor_health_detto.json"
def _carica_detti(path: Path = DETTO) -> set[str]:
if not path.exists():
return set()
try:
return set(json.loads(path.read_text()).get("detti", []))
except Exception:
return set()
def _salva_detti(detti: set[str], path: Path = DETTO) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(
{"detti": sorted(detti), "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
indent=2))
def main_orari() -> int:
"""(#30) Giro orario: solo i sorveglianti a max_age<=6h, notifica solo il NUOVO rispetto al
marcatore. Exit 2 se c'e' un alert (anche gia' detto), 0 altrimenti — il giro giornaliero
resta l'unico posto dove "nessun alert nuovo" vuol dire davvero "tutto OK"."""
quiet = "--quiet" in sys.argv
rows = check_all(ROOT, monitors=monitors_orari())
alerts = alerts_from(rows)
detti = _carica_detti(DETTO) # DETTO letto qui, non nel default del parametro (monkeypatch)
nuovi = alert_nuovi(rows, detti)
aggiornato = marcatore_aggiornato(rows, detti)
if not quiet or alerts:
print("=" * 92)
print(" MONITOR HEALTH — giro orario (sorveglianti con max_age <= 6h)")
print("=" * 92)
for r in rows:
cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-"
age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-"
print(f" {r['name']:<20}{r['status']:>10}{age:>13}{cov:>10}")
inviato = True
if nuovi:
msgs = alerts_from(nuovi)
inviato = notify("🩺 MONITOR HEALTH — un sorvegliante orario non sta registrando",
{f"alert {i+1}": m for i, m in enumerate(msgs)})
for m in msgs:
print(f" ALERT (nuovo): {m}")
if not inviato:
print(" ⚠️ invio FALLITO — marcatore NON aggiornato: il prossimo giro ritenta")
if not nuovi or inviato:
_salva_detti(aggiornato, DETTO)
return 2 if alerts else 0
def main() -> int: def main() -> int:
if "--solo-orari" in sys.argv:
return main_orari()
rows = check_all(ROOT) rows = check_all(ROOT)
alerts = alerts_from(rows) alerts = alerts_from(rows)
@@ -55,15 +126,17 @@ def main() -> int:
return 0 return 0
USO = """uso: monitor_health.py [--quiet] USO = """uso: monitor_health.py [--quiet] [--solo-orari]
monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla. 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 # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
max_age<=6h, notifica solo il NUOVO rispetto al marcatore
Dettaglio nel docstring in testa al file.""" Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida from src.live.cli import valida
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=()) valida("monitor_health.py", USO, flag=("--quiet", "--solo-orari"), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+88 -17
View File
@@ -25,17 +25,63 @@ import pandas as pd
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.monitor_health import MONITORS
from src.live.notifier import send from src.live.notifier import send
HORIZONS = (30, 90, 180) HORIZONS = (30, 90, 180)
GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"),
("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")]
def _gate_date(nome_monitor: str, cablata: date) -> date:
"""(#35 C14) la data del gate viene da monitor_health.MONITORS (`MonitorSpec.gate`), la
STESSA tabella che "METTE A RISCHIO il gate" negli alert dei sorveglianti: una fonte, non
una seconda dichiarazione duplicata di CLAUDE.md §4. Il campo e' prosa libera
("YYYY-MM-DD — descrizione"): si legge la data solo se e' nella forma attesa. `cablata' e'
rete di sicurezza se il monitor sparisse o il campo cambiasse forma — non deve succedere per
i due gate qui sotto, oggi allineati (verificato a mano contro CLAUDE.md §4)."""
for m in MONITORS:
if m.name == nome_monitor and m.gate:
try:
return date.fromisoformat(m.gate[:10])
except ValueError:
break
return cablata
GATES = [("STATARB-RESID", _gate_date("paper_statarb", date(2026, 9, 27)),
"data/paper_statarb/returns.jsonl", "net_modeled"),
("XSR01", _gate_date("paper_xsr", date(2026, 10, 23)),
"data/paper_xsr/returns.jsonl", "net_modeled")]
def _fmt_usd(x: float) -> str: def _fmt_usd(x: float) -> str:
return f"${x:,.2f}" return f"${x:,.2f}"
def _soglia_accensione(segni: list[int], neg_refs: list[float]) -> float | None:
"""(#35 C3) Quale riferimento, superato, accende TP01 (mean(segni) > 0)?
I segni sono sempre in {-1,+1}, mai 0 (trend_state li costruisce con `1 if px>refs[h]
else -1`: nessun pareggio possibile). Con k segni negativi da far diventare positivi, la
somma cresce di 2 per flip: serve il piu' piccolo k tale che sum(segni)+2k > 0. La soglia
e' il k-esimo riferimento negativo piu' basso (k=1 -> il minimo, come prima del fix; k=2 ->
il secondo, ecc.) — non sempre il minimo: con TUTTI i riferimenti negativi, superare solo il
piu' basso lascia la media <= 0 e TP01 a 0 (bug segnalato in #35 C3).
None se la media e' gia' positiva (nessuna soglia da dare) o se k supera il numero di
riferimenti negativi disponibili (non dovrebbe accadere: con n segni, flippare tutti gli n
negativi porta comunque la somma a +n > 0, quindi k <= len(neg_refs) sempre — resta una
guardia difensiva, non un caso noto)."""
s = sum(segni)
if s > 0 or not neg_refs:
return None
k = 0
while s + 2 * k <= 0:
k += 1
if k > len(neg_refs):
return None
return sorted(neg_refs)[k - 1]
def trend_state() -> list[str]: def trend_state() -> list[str]:
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi). """Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
@@ -59,32 +105,51 @@ def trend_state() -> list[str]:
tgt = max(0.0, m) tgt = max(0.0, m)
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \ line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
f" → TP01 {tgt:.2f}" f" → TP01 {tgt:.2f}"
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0] if tgt == 0.0:
if neg and tgt == 0.0: r = _soglia_accensione(list(sg.values()), [refs[h] for h in HORIZONS if sg[h] < 0])
h, r = min(neg, key=lambda t: t[1]) if r is not None:
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)" line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
out.append(line) out.append(line)
except Exception as e: except Exception as e:
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})") out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
return out return out
def book_state() -> list[str]: def book_state() -> tuple[list[str], bool | None]:
"""Conto reale, posizioni e target netto correnti. Sola lettura.""" """Conto reale, posizioni e target netto correnti. Sola lettura.
(#35 C14) Ritorna anche se il libro e' DAVVERO flat (nessuna posizione, nessun target: stessa
condizione che questa funzione gia' usa riga per riga per stampare "flat" invece di "pos ->
target"): serve a `build()` per decidere se l'header "Perche' non opera" e' vero o
fuorviante, SENZA una lettura nuova — None se lo stato del libro non e' leggibile (in quel
caso non sappiamo dire se opera)."""
try: try:
from src.live.book import book_report from src.live.book import book_report
r = book_report() r = book_report()
eq = r.get("equity") eq = r.get("equity")
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"] lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
flat = True
for a in r.get("assets", []): for a in r.get("assets", []):
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0) pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}" is_flat = abs(pos) < 1 and abs(net) < 1
flat = flat and is_flat
stato = "flat" if is_flat else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})") lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
if r.get("skh_error"): if r.get("skh_error"):
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}") lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
return lines return lines, flat
except Exception as e: except Exception as e:
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"] return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"], None
def _fmt_giorni(giorni: int) -> str:
"""(#35 C14) tre casi distinti, non un segno "−" cablato davanti a un intero che puo' essere
negativo: con giorni=-1 quel cablaggio stampava "(−-1g)", un doppio segno illeggibile."""
if giorni > 0:
return f"-{giorni}g"
if giorni == 0:
return "oggi"
return f"scaduto da {-giorni}g"
def monitors() -> list[str]: def monitors() -> list[str]:
@@ -92,20 +157,21 @@ def monitors() -> list[str]:
for name, dec, path, key in GATES: for name, dec, path, key in GATES:
p = ROOT / path p = ROOT / path
giorni = (dec - date.today()).days giorni = (dec - date.today()).days
gate_txt = f"gate {dec} ({_fmt_giorni(giorni)})"
if not p.exists(): if not p.exists():
out.append(f" {name}: in attesa della prima barra · gate {dec} (−{giorni}g)") out.append(f" {name}: in attesa della prima barra · {gate_txt}")
continue continue
try: try:
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()] rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
if not rows: if not rows:
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)") out.append(f" {name}: 0 barre · {gate_txt}")
continue continue
r = np.array([x[key] for x in rows], float) r = np.array([x[key] for x in rows], float)
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0 sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
tot = float(np.prod(1 + r) - 1) * 100 tot = float(np.prod(1 + r) - 1) * 100
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · gate {dec} (−{giorni}g)") out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · {gate_txt}")
except Exception: except Exception:
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)") out.append(f" {name}: log illeggibile · {gate_txt}")
return out return out
@@ -129,8 +195,13 @@ def last_trade() -> str:
def build() -> str: def build() -> str:
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC") oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"] L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
L += book_state() righe_conto, flat = book_state()
L += ["", "<b>Perche' non opera</b>"] L += righe_conto
# (#35 C14) "Perche' non opera" e' vero solo se il libro e' DAVVERO flat (nessuna posizione,
# nessun target: gia' letto sopra da book_state, nessuna lettura nuova). Se non e' flat (ha
# posizioni o un target diverso da zero) o non si sa (book non leggibile), l'header fisso
# mentirebbe: si usa un titolo neutro.
L += ["", "<b>Perche' non opera</b>" if flat else "<b>Stato trend</b>"]
L += trend_state() L += trend_state()
L += ["", "<b>Ordini</b>", last_trade()] L += ["", "<b>Ordini</b>", last_trade()]
L += ["", "<b>Monitor in osservazione</b>"] L += ["", "<b>Monitor in osservazione</b>"]
+42 -4
View File
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402 from src.live import tradesdb as T # noqa: E402
from src.live.notifier import notify # noqa: E402
def sync(verbose: bool = True) -> dict: def sync(verbose: bool = True) -> dict:
@@ -25,16 +26,51 @@ def sync(verbose: bool = True) -> dict:
fills = [f for r in runs for f in r.fills] fills = [f for r in runs for f in r.fills]
n_f = T.upsert_fills(con, fills) n_f = T.upsert_fills(con, fills)
n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None]) n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None])
# #40 (TradingBook#11): arricchimento PRIMA dei round-trip, cosi' il FIFO vede gia' i prezzi
# pieni e le ore vere delle righe abbinate al jsonl, non quelle (arrotondate, di inizio giro)
# di cron_book.log. `righe_jsonl` si legge una volta sola e serve anche al controllo di
# conteggio (#37 C11) qui sotto.
righe_jsonl = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
n_arricchiti, n_ambigui = T.arricchisci_da_jsonl(con, righe_jsonl)
n_r = T.rebuild_roundtrips(con) n_r = T.rebuild_roundtrips(con)
if runs: if runs:
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc) T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
T.set_meta(con, "ultimo_sync", T.ora()) T.set_meta(con, "ultimo_sync", T.ora())
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"] tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"]
# issue #37 (C11). Dal 2026-08-24T01:07Z (T.CUTOFF_TS_VERO) book_executions.jsonl porta l'ora
# VERA per-fill (docstring di tradesdb.py). Un conteggio diverso in quella finestra segnala
# che una delle due fonti sta perdendo fill — questo controllo resta indipendente
# dall'arricchimento sopra (che abbina riga per riga, non conta): un conteggio uguale non
# esclude un abbinamento ambiguo, e viceversa. Notify UNA volta per episodio: marcatore in
# `meta` (stessa regola di fee_watch #37 B9 — scritto solo se l'invio riesce, azzerato
# quando i conteggi tornano uguali).
n_jsonl_dal = sum(1 for r in righe_jsonl if str(r.get("ts_utc", "")) >= T.CUTOFF_TS_VERO)
n_db_dal = con.execute("SELECT COUNT(*) c FROM fills WHERE ts_utc >= ?",
(T.CUTOFF_TS_VERO,)).fetchone()["c"]
diverge = n_jsonl_dal != n_db_dal
gia_detto = T.get_meta(con, "conteggio_diverso_detto") == "1"
if diverge:
msg = f"⚠️ trades.db: {n_jsonl_dal} fill nel jsonl, {n_db_dal} nel DB dal 24/08"
if verbose:
print(f" {msg}")
if not gia_detto:
if notify("⚠️ trades.db — conteggio fill diverso dal jsonl dal 24/08",
{"fill nel jsonl": n_jsonl_dal, "fill nel DB": n_db_dal}):
T.set_meta(con, "conteggio_diverso_detto", "1")
elif gia_detto:
T.set_meta(con, "conteggio_diverso_detto", "0")
if verbose: if verbose:
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}") print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
print(f" arricchiti dal jsonl: {n_arricchiti} | ambigui: {n_ambigui}")
con.close() con.close()
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot) return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot,
jsonl_dal_24_08=n_jsonl_dal, db_dal_24_08=n_db_dal,
arricchiti=n_arricchiti, ambigui=n_ambigui)
def reconcile() -> None: def reconcile() -> None:
@@ -42,8 +78,8 @@ def reconcile() -> None:
fills = [f for r in runs for f in r.fills] fills = [f for r in runs for f in r.fills]
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else [] righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
rec = T.reconcile(fills, righe) rec = T.reconcile(fills, righe)
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)") print(f"\n cron_book.log : {len(fills)} fill (ora di inizio giro)")
print(f" book_executions : {len(righe)} righe (senza ora)") print(f" book_executions : {len(righe)} righe (ora VERA per-fill dal 24/08, #37 C11)")
print(f" concordano : {len(rec['ok'])}") print(f" concordano : {len(rec['ok'])}")
print(f" solo nel log : {len(rec['solo_log'])}") print(f" solo nel log : {len(rec['solo_log'])}")
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}") print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
@@ -125,7 +161,9 @@ def report() -> None:
if ap: if ap:
print(" posizioni aperte :") print(" posizioni aperte :")
for a, d in sorted(ap.items()): for a, d in sorted(ap.items()):
print(f" {a} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} (dal {d['dal'][:16]})") marca = "" if d.get("direzione", "long") == "long" else " SHORT" # #32 C2
print(f" {a}{marca} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} "
f"(dal {d['dal'][:16]})")
else: else:
print(" posizioni aperte : nessuna (flat)") print(" posizioni aperte : nessuna (flat)")
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}") print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
+80
View File
@@ -61,6 +61,18 @@ reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa
uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch) uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine
LOCK SUL CONTO (#36). Un lancio a MANO con --esegui condivide il conto con cuscino_watch e
book_execute (cron_cuscino.sh/cron_book.sh, entrambi dentro `flock -n logs/.conto.lock`): senza
lo stesso lock un ordine a mano nello stesso minuto del cron puo' convertire due volte verso
bersagli diversi (limite D5 gia' dichiarato sopra). Si lancia quindi con:
flock logs/.conto.lock uv run python scripts/live/usde_convert.py --quota 0.64 --esegui
⚠️ Qui NON senza `-n`: un lancio a mano puo' aspettare il turno invece di saltare. Questo script
stesso NON prende il lock (vedi cuscino_watch.py): se lo facesse e girasse come figlio di
cuscino_watch (che il lock lo tiene gia'), un secondo `flock -n` lo troverebbe occupato e
fallirebbe subito, per costruzione, ogni singola volta.
""" """
from __future__ import annotations from __future__ import annotations
@@ -93,6 +105,59 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui. # L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cuscino_richiesto_usd = U.cuscino_richiesto_usd cuscino_richiesto_usd = U.cuscino_richiesto_usd
# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script
# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca).
LABEL_PREFIX = "usde-quota-"
def _cancella(c, order_id: str) -> tuple[bool, str]:
"""Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la
lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche'
il chiamante deve poter fermarsi con un messaggio invece di un traceback."""
try:
resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
except Exception as e: # noqa: BLE001 — si registra (P3)
return False, f"{type(e).__name__}: {e}"
if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error":
return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp)
return True, ""
def cancella_residui(c) -> list[str]:
"""issue #26 — GTC mai cancellati. Il gateway cerbero-mcp NON accetta `time_in_force`
(PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit
riempito solo in parte resta APERTO per sempre e si accumula a ogni giro invece di sparire
con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label
(`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca.
-> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non
si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio)."""
# non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC (quotazione USDC o
# base USDE): si interrogano entrambe e si unisce per order_id, cosi' non si perde il residuo
# se il venue lo elenca sotto la base.
visti = {}
for valuta in ("USDC", "USDE"):
out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders",
{"currency": valuta, "kind": "spot", "type": "all"}))
ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
for o in ordini:
if isinstance(o, dict) and o.get("order_id") is not None:
visti[o["order_id"]] = o
ordini = list(visti.values())
cancellati = []
for o in ordini:
if not isinstance(o, dict):
continue
instrument = o.get("instrument") or o.get("instrument_name")
label = o.get("label") or ""
if instrument == U.SPOT and label.startswith(LABEL_PREFIX):
oid = o.get("order_id")
ok, motivo = _cancella(c, oid)
if not ok:
raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}")
cancellati.append(oid)
return cancellati
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]: def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non """Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
@@ -232,6 +297,9 @@ def main() -> int:
return 0 return 0
LOG.parent.mkdir(parents=True, exist_ok=True) LOG.parent.mkdir(parents=True, exist_ok=True)
residui = cancella_residui(c)
print(f" residui cancellati all'avvio: {len(residui)}"
+ (f" ({', '.join(residui)})" if residui else ""))
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0 chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
for _ in range(60): for _ in range(60):
eq_usdc, eq_usde, px, _f = leggi_stato(c) eq_usdc, eq_usde, px, _f = leggi_stato(c)
@@ -274,6 +342,18 @@ def main() -> int:
"amount": amt, "filled": filled}) "amount": amt, "filled": filled})
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}" print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})") f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
# issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non
# accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si
# FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo.
if filled < amt:
oid = order.get("order_id")
ok, motivo = _cancella(c, oid)
fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}"
if not ok:
print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}"
" — mi fermo (mai un secondo ordine con un residuo vivo)")
break
print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})")
if filled <= 0: if filled <= 0:
print(" ✗ ordine accettato ma non fillato — mi fermo"); break print(" ✗ ordine accettato ma non fillato — mi fermo"); break
time.sleep(a.pausa) time.sleep(a.pausa)
+51 -50
View File
@@ -58,12 +58,6 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003 FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
# ogni giorno finche' la riga non viene tolta.
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 ----------------- # --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
@@ -162,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato") nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
if verdetto_corrente != "IDONEO":
return False
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
def condizioni(rec: dict | None, c: dict) -> set[str]: def condizioni(rec: dict | None, c: dict) -> set[str]:
"""PURA. Condizioni di allerta attive su un record.""" """PURA. Condizioni di allerta attive su un record."""
if not rec: if not rec:
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
return att return att
def nuove_condizioni(att_now: set[str], att_prev: set[str], prev: dict | None) -> set[str]:
"""PURA (issue #29 B2). Le condizioni da notificare in questo giro.
Fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si leggeva: un
invio fallito restava "gia' detto" per l'episodio intero (l'ora dopo att_prev diceva ancora
che la condizione c'era, quindi non era piu' "nuova"). Schema di cuscino_watch.py: una
condizione conta come "gia' detta" solo se era attiva l'ultima volta E il suo invio e'
RIUSCITO — cioe' compare in `allertati` del record precedente. Un record vecchio senza
`allertati` (scritto prima di questo fix) vale come "tutto allertato": nessun rispam
retroattivo delle condizioni gia' viste."""
confermate_prev = set((prev or {}).get("allertati", att_prev))
return att_now - (att_prev & confermate_prev)
def _safe_client(): def _safe_client():
try: try:
from src.live.deribit import DeribitRead from src.live.deribit import DeribitRead
@@ -284,54 +285,58 @@ def main() -> int:
v, v_motivo = verdetto(records + [rec], now) v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
STATE.parent.mkdir(parents=True, exist_ok=True) # --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
with STATE.open("a") as fh: # issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
fh.write(json.dumps(rec) + "\n") # non si leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema di
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit --- # si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
att_now, att_prev = condizioni(rec, c), condizioni(prev, c) att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
nuove = att_now - att_prev nuove = nuove_condizioni(att_now, att_prev, prev)
confermate_prev = set((prev or {}).get("allertati", att_prev))
inviati = [] inviati = []
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
if "DEPEG_CRIT" in att_now: if "DEPEG_CRIT" in att_now:
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"], ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
"collaterale": f"${usd:,.0f}" if usd else "?", "collaterale": f"${usd:,.0f}" if usd else "?",
"azione": "valutare riconversione — la quota e' il controllo (N4)"}) "azione": "valutare riconversione — la quota e' il controllo (N4)"})
inviati.append("DEPEG_CRIT") inviati.append("DEPEG_CRIT")
if ok:
allertati.add("DEPEG_CRIT")
if "DEPEG_WARN" in nuove: if "DEPEG_WARN" in nuove:
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"], ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"}) "nota": "entra gia' nel sizing orario del book"})
inviati.append("DEPEG_WARN") inviati.append("DEPEG_WARN")
if ok:
allertati.add("DEPEG_WARN")
if "QUOTA_OVER" in nuove: if "QUOTA_OVER" in nuove:
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?", ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
"tetto": f"{c['quota_max_frac']:.0%}", "tetto": f"{c['quota_max_frac']:.0%}",
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"}) "nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
inviati.append("QUOTA_OVER") inviati.append("QUOTA_OVER")
if ok:
allertati.add("QUOTA_OVER")
if "BLIND" in nuove: if "BLIND" in nuove:
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?", ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"}) "nota": "'non vedo' non e' 'va tutto bene' (P5)"})
inviati.append("BLIND") inviati.append("BLIND")
if ok:
allertati.add("BLIND")
rec["allertati"] = sorted(allertati)
v_prev = prev.get("verdetto") if prev else "IN_ATTESA" v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
if v != v_prev: if v != v_prev:
if v == "IDONEO": if v == "IDONEO":
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo, notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
"prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"}) "prossimo passo": f"decisione di quota dell'operatore (tetto allerta {c['quota_max_frac']:.0%})"})
elif v == "NON_IDONEO": elif v == "NON_IDONEO":
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo}) notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
inviati.append(f"VERDETTO->{v}") inviati.append(f"VERDETTO->{v}")
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
rend = rendimento(records + [rec]) rend = rendimento(records + [rec])
if quota_da_decidere(now, v):
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo" STATE.parent.mkdir(parents=True, exist_ok=True)
notify("📌 USDE: la decisione di QUOTA e' dovuta", { with STATE.open("a") as fh:
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d", fh.write(json.dumps(rec) + "\n")
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
inviati.append("QUOTA_DA_DECIDERE")
cambiato = bool(inviati) or v != v_prev cambiato = bool(inviati) or v != v_prev
if not quiet or cambiato: if not quiet or cambiato:
@@ -344,14 +349,12 @@ def main() -> int:
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}" print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d") f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
if quota is not None: if quota is not None:
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08): # Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il # dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross, # NON e' piu' il Segregated Standard Margin di prima di quella data.
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
cross = eq_usdc + usd * (1.0 - c["haircut"]) cross = eq_usdc + usd * (1.0 - c["haircut"])
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})") print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non" print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})") f" {c['haircut']:.0%})")
if an["delta"] is not None: if an["delta"] is not None:
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})" attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
@@ -366,8 +369,6 @@ def main() -> int:
f" (tot {rend['tot']:+.6f} USDE)") f" (tot {rend['tot']:+.6f} USDE)")
if rend["finestre"] < 4: if rend["finestre"] < 4:
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso") print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
if v == "IDONEO" and not quota_da_decidere(now, v):
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
if inviati: if inviati:
print(f" allarmi : {', '.join(inviati)}") print(f" allarmi : {', '.join(inviati)}")
return 0 return 0
+2
View File
@@ -31,6 +31,8 @@ def _titolo(rep: dict) -> str:
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero.""" per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
if rep.get("severity") == "blind":
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
if rep.get("severity") == "warn": if rep.get("severity") == "warn":
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione" return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova" return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
+27 -7
View File
@@ -206,9 +206,22 @@ def testata(voce: dict) -> str:
p, lb = voce.get("pnl") or {}, voce.get("libro") or {} p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming") dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
eq = p.get("equity_fine") eq = p.get("equity_fine")
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: "oggi" e' ieri -> "nel giorno". E se
# ci sono movimenti di capitale CERTI (mov_giorno; bool() come journal.scorporo: assente o
# 0 -> nessun movimento) la cifra da mostrare e' trading_giorno (al netto), non l'equity
# grezza: altrimenti un versamento si legge come P&L (successo il 25/08, +$1.399 letti come
# profitto). Se trading_giorno non e' calcolabile si resta su delta_equity, ma con l'etichetta
# nuova: non e' "oggi", e non e' comunque il trading al netto.
dq_t = p.get("trading_giorno")
if p.get("mov_giorno") and dq_t is not None:
nota_dq = f" ({dq_t:+.2f} nel giorno, di cui {p['mov_giorno']:+.2f} movimenti)"
elif dq is not None:
nota_dq = f" ({dq:+.2f} nel giorno)"
else:
nota_dq = ""
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)") + nota_dq
+ ("" 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" (di cui {p['mov_arming']:+.2f} versati -> trading "
f"{p['trading_da_arming']:+.2f})" f"{p['trading_da_arming']:+.2f})"
@@ -228,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None
""" """
testo = testata(voce) testo = testata(voce)
if analisi.strip(): if analisi.strip():
corpo = _b(analisi.strip()) grezzo = analisi.strip()
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
if stato == "sospetta": if stato == "sospetta":
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>" testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
testo += "\n\n" + corpo # (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **-><b>: un
# taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un <b>
# aperto, e Telegram rifiuta l'INTERO messaggio ("can't parse entities") — il taglio si
# perde tutto, non si accorcia. Il marcatore si aggiunge dopo, fuori dall'escape (e'
# testo nostro, senza `&`/`<`: non ha bisogno di escape).
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
budget = LIMITE_TELEGRAM - len(testo) - len("\n\n") - len(taglio)
tagliata = len(grezzo) > budget
if tagliata:
grezzo = grezzo[:max(budget, 0)]
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", _b(grezzo)) # il grassetto del giornale
testo += "\n\n" + corpo + (taglio if tagliata else "")
else: else:
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): " testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>") + _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
if len(testo) > LIMITE_TELEGRAM:
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
return testo return testo
+42 -5
View File
@@ -4,8 +4,8 @@ TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit e
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo. software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni Formula (preserva il budget di rischio per-asset di config (CAP sotto: `max_notional_per_asset_usd`
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): o `_frac`), split 75/25; coerente col blend di ritorni deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP ) net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
@@ -13,8 +13,8 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m) skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
E = equity reale del conto (fallback paper se offline) E = equity reale del conto (fallback paper se offline)
CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE CAP = cap notional per-asset. Fisso = `max_notional_per_asset_usd` di config, OPPURE
DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la DINAMICO = equity * `max_notional_per_asset_frac` (es. 0.5 -> equity/2) se la
frazione e' presente in config -> il cap cresce col capitale e non strozza il frazione e' presente in config -> il cap cresce col capitale e non strozza il
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
fidata; su fallback/offline si torna al cap fisso (protezione downside). fidata; su fallback/offline si torna al cap fisso (protezione downside).
@@ -136,6 +136,42 @@ def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
return s return s
def leva_tetto(cfg: dict | None = None) -> tuple[float, str]:
"""Leva lorda massima che il libro PUO' prendere. UNA formula, non tre (#28): prima era
ridichiarata in `usde.frazione_cuscino` (N_ASSET fisso, senza scala) e nella regola `leva` del
giornale (senza scala) -> a scala 1,25 il cuscino USDC risultava 0,30 dell'equity invece di
0,375, e la regola `leva` mostrava un tetto diverso da quello che il libro rispetta davvero.
leva = len(ASSETS) x min(WEIGHT, frac) x scala, al piu' LEVA_LORDA_MAX. -> (leva, come).
- frac = cfg["max_notional_per_asset_frac"] se presente, altrimenti WEIGHT. Il min(WEIGHT, frac)
viene dal fatto che `book_net_target` non supera mai weight*equity*(W_TP01+W_SKH) =
weight*equity (i due pesi sommano a 1): |raw| <= weight*equity, quindi |net_target| <=
scala*min(cap, weight*equity) -- un frac oltre WEIGHT alza il cap nominale ma non la leva
REALE che il libro puo' esprimere.
- scala = la scala DICHIARATA (`_scala(real_equity=1.0, cfg=cfg)`, gia' validata): un tetto
descrive quanto la config AUTORIZZA, non quanto un giro con equity illeggibile ha preso.
- Su `ScalaNonAutorizzata` (config fuori scaletta o fuori tetto) NON si solleva: un
sorvegliante (`cuscino_watch`) non deve morire proprio quando la config e' sbagliata
(verifica fable B5) -> si ritorna il tetto di CODICE, dichiarando l'anomalia in `come`.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
try:
scala = _scala(real_equity=1.0, cfg=cfg)
except ScalaNonAutorizzata as e:
breve = str(e).splitlines()[0]
breve = breve if len(breve) <= 80 else breve[:77] + "..."
return LEVA_LORDA_MAX, (f"config fuori scaletta/tetto ({breve}): "
f"tetto di codice LEVA_LORDA_MAX")
raw_frac = cfg.get("max_notional_per_asset_frac")
frac = float(raw_frac) if raw_frac is not None else WEIGHT
frac_eff = min(WEIGHT, frac)
leva = len(ASSETS) * frac_eff * scala
base = f"{len(ASSETS)} asset x frac {frac_eff} x scala {scala} = {leva:.2f}x"
if leva > LEVA_LORDA_MAX + 1e-12:
return LEVA_LORDA_MAX, f"{base}, troncato al tetto di codice {LEVA_LORDA_MAX}x"
return leva, base
def _read_watermark() -> float | None: def _read_watermark() -> float | None:
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile.""" """Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
@@ -269,6 +305,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) -> live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato, segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero).""" deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
sh = shadow_report(offline=offline, equity_override=equity_override) sh = shadow_report(offline=offline, equity_override=equity_override)
equity = sh["equity"] equity = sh["equity"]
# registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di # registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di
@@ -315,7 +352,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
net = book_net_target(tp_frac, sign, equity, cap, scala=scala) net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
cur = float(a_rec["position_usd"]) cur = float(a_rec["position_usd"])
mark = a_rec["mark"] mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=5.0) order = build_book_order(inst, net, cur, mark, min_usd=float(cfg.get("min_order_usd", 5.0)))
if order: if order:
orders.append(order) orders.append(order)
assets.append(dict( assets.append(dict(
+37 -17
View File
@@ -1,13 +1,16 @@
"""Accesso Deribit MAINNET in SOLA LETTURA (via Cerbero MCP) + costruttore ordini deterministico. """Accesso Deribit MAINNET (via Cerbero MCP) + costruttore ordini deterministico. La classe
`DeribitRead` qui e' in SOLA LETTURA (nessun metodo di trading, by design); l'esecuzione REALE la
estende in un modulo separato (`src.live.execution.DeribitTrader`) — non e' piu' un progetto
futuro (#35 A9): e' ARMATA e LIVE su Deribit mainnet dal 2026-06-23 (`execution_enabled` in
config/live.json), usata da `scripts/live/book_execute.py`.
Serve lo SHADOW MODE di TP01 (`scripts/live/live_trend.py`): legge prezzi / conto / posizioni REALI Da qui vengono prezzi / conto / posizioni REALI (token `.env.mainnet`): li usa sia il libro live
dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON (TP01+SKH01, via src.live.book + execution) sia lo shadow mode e i monitor di sorveglianza.
esiste alcun metodo di trading — by design: l'unica via per piazzare ordini sara' un modulo separato,
abilitato esplicitamente, dopo la validazione shadow + micro-test.
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale, Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet
e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount` reale. I contratti ESEGUITI sono i perp LINEARI USDC (`_USDC-PERPETUAL`, non inverse): `amount` in
in USD notional, step verificato su Deribit: BTC $10, ETH $1). base-coin (BTC/ETH), margine e P&L in USDC — vedi `_CONTRACT` sotto per step/tick/minimi per
strumento.
""" """
from __future__ import annotations from __future__ import annotations
@@ -236,11 +239,22 @@ class DeribitRead:
return out.get("positions", []) if isinstance(out, dict) else [] return out.get("positions", []) if isinstance(out, dict) else []
def position_usd(self, instrument: str) -> float: def position_usd(self, instrument: str) -> float:
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.""" """Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.
⚠️ Il gateway manda `size`/`size_currency` in ASSOLUTO e il segno a parte in `direction`
(#17): letta com'era, uno short risultava un long, e close()/ensure_disaster_sl decidevano
lato ordine e prezzo dello stop dal segno sbagliato. `direction` assente o `"zero"` -> ci si
fida di `size` cosi' com'e' (compatibile con un gateway che un giorno mandasse gia' il segno)."""
cur = _CONTRACT[instrument]["settle"] cur = _CONTRACT[instrument]["settle"]
for p in self.positions(cur): for p in self.positions(cur):
if p.get("instrument_name") == instrument or p.get("instrument") == instrument: if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
return float(p.get("size") or p.get("size_currency") or 0.0) s = float(p.get("size") or p.get("size_currency") or 0.0)
direction = str(p.get("direction") or "").lower()
if direction in ("sell", "short"):
return -abs(s)
if direction in ("buy", "long"):
return abs(s)
return s
return 0.0 return 0.0
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]: def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
@@ -251,17 +265,23 @@ class DeribitRead:
def open_orders(self, instrument: str) -> list[dict]: def open_orders(self, instrument: str) -> list[dict]:
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger """Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.""" untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.
⚠️ Se UNA delle due chiamate solleva, NON si ingoia (#24): prima ritornava [] anche a meta'
letta, e un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione
persa su 'all' — il chiamante (ensure_disaster_sl) deve saperlo, non decidere su dati a
meta'. Il chiamante che vuole restare in piedi comunque (shadow.shadow_report) si mette il
try/except da solo."""
cur = _CONTRACT[instrument]["settle"] cur = _CONTRACT[instrument]["settle"]
seen: dict = {} seen: dict = {}
for typ in ("all", "trigger_all"): for typ in ("all", "trigger_all"):
try: try:
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders", out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
{"currency": cur, "type": typ})) {"currency": cur, "type": typ}))
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
for o in lst: raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
if o.get("instrument_name") == instrument or o.get("instrument") == instrument: lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
seen[o.get("order_id")] = o for o in lst:
except Exception: if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
pass seen[o.get("order_id")] = o
return list(seen.values()) return list(seen.values())
+99 -39
View File
@@ -4,12 +4,17 @@ Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA
(Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee (Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee
reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only. reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only.
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE). DEFAULT per chi non lo chiama (il micro-test, size fissa minima). Le CHIUSURE si tentano SEMPRE
senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). Nessun
parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py). ⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente. 2026-06-23 (commit db738bc): questo NON e' un percorso di micro-test, e' l'esecuzione di produzione
usata da `scripts/live/book_execute.py` (cron del book). Il micro-test
(scripts/live/microtest.py) resta un percorso a parte, sotto lo stesso guardrail, col cap di
DEFAULT (MAX_AMOUNT).
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,11 +22,14 @@ from dataclasses import dataclass
from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger # Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello # DEFAULT (in coin, sotto), usato SOLO quando nessuno ha chiamato set_open_cap() (il micro-test,
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima. # che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva set_open_cap()
# dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop sopra il sizing
# di TP01/book, non il sizing operativo. (#35 A9: via il "~$630/$430 su conto ~$600" cablato —
# MAX_AMOUNT e' in coin, non in dollari, e non e' il tetto operativo da quando esiste set_open_cap.)
ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"}
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap()
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
@@ -29,6 +37,19 @@ class GuardrailError(RuntimeError):
pass pass
class RebalanceParziale(RuntimeError):
"""`rebalance_signed` ha eseguito almeno un fill (es. il close di un flip) e POI un passo
successivo ha sollevato (es. l'open dall'altro lato). Il fill gia' eseguito NON e' un fill
perso: `.fills` li porta al chiamante (che li registra) e `.causa` e' l'eccezione originale
(il chiamante la rilancia dentro il suo handler di isolamento, #23)."""
def __init__(self, fills: list["Fill"], causa: Exception):
self.fills = fills
self.causa = causa
super().__init__(f"rebalance parziale: {len(fills)} fill eseguiti, poi "
f"{type(causa).__name__}: {causa}")
@dataclass @dataclass
class Fill: class Fill:
"""Esito verificato di un ordine reale.""" """Esito verificato di un ordine reale."""
@@ -55,6 +76,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
class DeribitTrader(DeribitRead): class DeribitTrader(DeribitRead):
"""Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre.""" """Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre."""
def __init__(self) -> None:
super().__init__()
self._open_cap: dict[str, float] = {} # per-strumento, da set_open_cap() (#18)
def set_open_cap(self, instrument: str, usd_cap: float, mark: float) -> None:
"""Tetto di apertura per `instrument`, derivato dal cap di config del libro (#18):
amount_max = 1,10 * usd_cap / mark — 10% sopra il cap nozionale per non rifiutare
un'apertura legittima per un arrotondamento di sizing/mark. Backstop anti-fat-finger
sopra il sizing di TP01/book, non il sizing operativo (quello resta config/live.json).
Senza chiamata, `_submit` resta sul DEFAULT MAX_AMOUNT (il micro-test)."""
if usd_cap <= 0 or mark <= 0:
raise ValueError(f"set_open_cap richiede usd_cap>0 e mark>0 (usd_cap={usd_cap}, mark={mark})")
self._open_cap[instrument] = 1.10 * usd_cap / mark
def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool, def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool,
label: str, order_type: str = "market", price: float | None = None) -> Fill: label: str, order_type: str = "market", price: float | None = None) -> Fill:
if instrument not in ALLOWED: if instrument not in ALLOWED:
@@ -62,7 +97,9 @@ class DeribitTrader(DeribitRead):
if side not in ("buy", "sell"): if side not in ("buy", "sell"):
raise GuardrailError(f"side non valido: {side}") raise GuardrailError(f"side non valido: {side}")
if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre
cap = MAX_AMOUNT.get(instrument, 0.0) # getattr, non self._open_cap diretto: un DeribitTrader creato con object.__new__
# (test che saltano __init__) non ha l'attributo, e qui non deve dare AttributeError.
cap = getattr(self, "_open_cap", {}).get(instrument, MAX_AMOUNT[instrument])
if amount <= 0 or amount > cap: if amount <= 0 or amount > cap:
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]") raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
if amount <= 0: if amount <= 0:
@@ -150,36 +187,46 @@ class DeribitTrader(DeribitRead):
- target ~0 -> close(); - target ~0 -> close();
- stesso segno, |target|<|cur| -> REDUCE reduce_only; - stesso segno, |target|<|cur| -> REDUCE reduce_only;
- apertura/aumento -> open buy/sell (capped dal guardrail apertura). - apertura/aumento -> open buy/sell (capped dal guardrail apertura).
Ritorna i Fill eseguiti.""" Ritorna i Fill eseguiti.
⚠️ FILL PERSI (#23). Un flip e' DUE passi (close, poi open dall'altra parte): se il primo
va a segno e il secondo solleva, il fill del primo passo NON deve sparire. Da qui in poi
un'eccezione con almeno un fill gia' in `fills` diventa `RebalanceParziale(fills, causa)`
invece di propagare nuda: il chiamante registra i fill fatti e poi rilancia `causa`."""
cur = self.position_usd(instrument) cur = self.position_usd(instrument)
if abs(target_notional_usd - cur) < min_usd: if abs(target_notional_usd - cur) < min_usd:
return [] return []
fills: list[Fill] = [] fills: list[Fill] = []
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ try:
(cur < -FLAT_USD and target_notional_usd > FLAT_USD) crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
if crossing: # flip: flatta, poi riparti da zero (cur < -FLAT_USD and target_notional_usd > FLAT_USD)
f = self.close(instrument, label="book-flip-close") if crossing: # flip: flatta, poi riparti da zero
if f: f = self.close(instrument, label="book-flip-close")
fills.append(f)
cur = 0.0
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
if abs(cur) > FLAT_USD:
f = self.close(instrument, label="book-exit")
if f: if f:
fills.append(f) fills.append(f)
cur = 0.0
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
if abs(cur) > FLAT_USD:
f = self.close(instrument, label="book-exit")
if f:
fills.append(f)
return fills
delta = target_notional_usd - cur
amount = notional_to_amount(instrument, abs(delta), price=mark)
if amount <= 0:
return fills
same_sign = (target_notional_usd > 0) == (cur > 0)
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
else:
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
fills.append(self.open(instrument, side, amount, label="book-open"))
return fills return fills
delta = target_notional_usd - cur except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
amount = notional_to_amount(instrument, abs(delta), price=mark) if fills:
if amount <= 0: raise RebalanceParziale(fills, e) from e
return fills raise
same_sign = (target_notional_usd > 0) == (cur > 0)
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
else:
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
fills.append(self.open(instrument, side, amount, label="book-open"))
return fills
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) --- # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
def place_disaster_sl(self, instrument: str, side_held: str, amount: float, def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
@@ -194,10 +241,20 @@ class DeribitTrader(DeribitRead):
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict: def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente): """Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
- flat -> cancella eventuali bracket orfani;
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione; Stati possibili (`dict["state"]`):
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap). - `"flat"` -> nessuna posizione: cancella eventuali bracket orfani;
- `"ok"` -> gia' coerente (1 bracket, amount~=, stop entro 5%): lascia com'e';
- `"placed"` -> ricostruito (cancellato l'incoerente e ripiazzato) al primo o secondo
tentativo, e il piazzamento e' verificato;
- `"place-failed"` -> il ripiazzamento e' partito ma NON e' verificato (state/trade non
confermano): l'ordine potrebbe non essere a book;
- `"naked"` -> il bracket incoerente e' stato cancellato e il ripiazzamento e'
fallito (eccezione) 2 volte di fila: la posizione e' SENZA stop;
- `"unknown"` -> posizione o open_orders illeggibili (#24): NON cancella ne' piazza —
decidere alla cieca su meta' dei dati e' peggio che aspettare il giro
dopo. `notes` porta tipo ed errore della lettura fallita.
⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima ⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
@@ -216,8 +273,11 @@ class DeribitTrader(DeribitRead):
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
misurarlo. Riparato il silenzio, non toccata la sequenza. misurarlo. Riparato il silenzio, non toccata la sequenza.
""" """
pos = self.position_usd(instrument) try:
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL] pos = self.position_usd(instrument)
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
except Exception as e: # noqa: BLE001 — si registra il motivo (P3); non si cancella ne' piazza (#24)
return {"state": "unknown", "notes": f"{type(e).__name__}: {e}"}
if abs(pos) < FLAT_USD: if abs(pos) < FLAT_USD:
for o in brackets: for o in brackets:
self.cancel_order(o.get("order_id")) self.cancel_order(o.get("order_id"))
+61 -27
View File
@@ -192,12 +192,10 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi) return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il # la leva lorda massima che il libro puo' tenere: UNA sola formula, in `book.leva_tetto`
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del # (P1: derivata da `book`, non ridichiarata — #28 ne aveva tre diverse fra qui,
# 01/09 entra qui da sola) # usde.frazione_cuscino e la regola `leva` sotto, e non tornavano)
scala = float(cfg.get("book_scale_k") or 1.0) leva_tetto = _book.leva_tetto(cfg)[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 = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
px = {a: _px_1h(a) for a in ASSETS} if candidati else {} px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
@@ -248,12 +246,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "") + (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
+ (f": {d['nota']}" if d["nota"] else "") + (f": {d['nota']}" if d["nota"] else "")
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else "")) + (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None) # (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato # cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None, # intervallo troverebbe il primo (gia' "dichiarato") e lo SOVRASCRIVEREBBE, perdendo
# il primo importo invece di somarli.
rilevato = next((e for e in eventi
if e["ts_dopo"] == cur["ts_utc"] and e["fonte"] == "rilevato"), None)
if rilevato is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
rilevato.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
classe="movimento", fonte="dichiarato+rilevato", classe="movimento", fonte="dichiarato+rilevato",
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d) dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
continue continue
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]
and e["fonte"] in ("dichiarato", "dichiarato+rilevato")), None)
if gia is not None: # un altro dichiarato nello stesso intervallo: si SOMMA
nuovo_delta = gia["delta"] + d["delta"]
gia.update(delta=nuovo_delta,
pct=(nuovo_delta / float(gia["prima"]) if gia["prima"] else None),
dopo=float(gia["prima"]) + nuovo_delta,
dichiarazione=(gia["dichiarazione"] + "; " + nota_d))
continue
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(prev["equity"]) + d["delta"], prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
delta=d["delta"], delta=d["delta"],
@@ -376,16 +388,23 @@ def pnl_giorno(con, giorno: date) -> dict:
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"]) twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9)
def salute(con, giorno: date) -> dict: def salute(con, giorno: date) -> dict:
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso. """Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un
allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero. allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero.
Il giro dell'ora CORRENTE scatta al minuto `MINUTO_GIRO` (il cron e' `47 * * * *`): prima
di quel minuto non e' ancora avvenuto, e contarlo comunque (#35 C9) segnalava un giro
"mancante" che semplicemente non era ancora il suo turno.
""" """
lb = stato_libro(con, giorno) lb = stato_libro(con, giorno)
adesso = datetime.now(timezone.utc) adesso = datetime.now(timezone.utc)
in_corso = giorno == adesso.date() in_corso = giorno == adesso.date()
atteso = (adesso.hour + 1) if in_corso else 24 atteso = (adesso.hour + (1 if adesso.minute >= MINUTO_GIRO + 1 else 0)) if in_corso else 24
fatti = lb.get("giri", 0) fatti = lb.get("giri", 0)
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso, return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti)) giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti))
@@ -546,17 +565,17 @@ def lettura(con, voce: dict) -> list[dict]:
# --- quanto capitale e' esposto ---------------------------------------------------------- # --- quanto capitale e' esposto ----------------------------------------------------------
lv = lb.get("leva_lorda") lv = lb.get("leva_lorda")
if lv is not None: if lv is not None:
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo # Il tetto si LEGGE da `book.leva_tetto`, non ridichiarato qui. Ridichiararlo e' lo schema
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura # che questo progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap,
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio # taratura venue_watch, indice del namespace) — e una quinta, con una formula diversa da
# bersaglio continua a passare il giorno che il bersaglio cambia. # quella di `movimenti_capitale` e di `usde.frazione_cuscino`, e' quella corretta da #28.
from src.live import book as _book # lazy: prende il tetto vivo, anche monkeypatchato
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
cap_frac = float(cfg["max_notional_per_asset_frac"]) tetto, come = _book.leva_tetto(cfg)
tetto = cap_frac * len(lb["asset"])
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x " dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
f"(`max_notional_per_asset_frac` {cap_frac:.2f} x {len(lb['asset'])} " f"({come}, da `config/live.json` e `book.leva_tetto`): "
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.") f"margine {tetto - lv:.2f}x.")
except Exception as e: except Exception as e:
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile " dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
f"({type(e).__name__} su config/live.json).", "attenzione") f"({type(e).__name__} su config/live.json).", "attenzione")
@@ -591,16 +610,19 @@ def lettura(con, voce: dict) -> list[dict]:
# il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento # il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento
# diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto) # diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto)
mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"] mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"]
g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat()
tot = e1 - e0 - sum(e["delta"] for e in mv_ev) tot = e1 - e0 - sum(e["delta"] for e in mv_ev)
picco = max(r["equity"] for r in eq) # (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s] # prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s) # data (>= giorno-7) include anche le ore del giorno-7 gia' trascorse, cioe' 8 giorni di
# calendario, non 7.
g7_cutoff = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() + "T23:59:59+00:00"
base7 = next((r for r in reversed(eq) if r["ts_utc"] <= g7_cutoff), None)
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"] > g7_cutoff)
# Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica # Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica
# vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione. # vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione.
rilevante = abs(tot) >= max(5.0, 0.01 * e1) rilevante = abs(tot) >= max(5.0, 0.01 * e1)
if rilevante and g7: if rilevante and base7 is not None:
quota = (e1 - g7[0]["equity"] - mv7) / tot quota = (e1 - base7["equity"] - mv7) / tot
if quota > 0.60: if quota > 0.60:
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: " coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
"e' un evento.") "e' un evento.")
@@ -609,10 +631,22 @@ def lettura(con, voce: dict) -> list[dict]:
"senza questi 7 giorni il libro sarebbe sotto. " + coda) "senza questi 7 giorni il libro sarebbe sotto. " + coda)
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato " dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota") f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
if picco > 0: # (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
dd = (e1 - picco) / picco # TWR scorpora): sulla serie grezza un prelievo si legge come drawdown di trading e un
# versamento alza il picco, mascherando un DD vero appena prima del versamento.
mv_ord = sorted(mv_ev, key=lambda e: e["ts_dopo"])
cum_mov, i_mov, eq_netto = 0.0, 0, []
for r in eq:
while i_mov < len(mv_ord) and r["ts_utc"] >= mv_ord[i_mov]["ts_dopo"]:
cum_mov += mv_ord[i_mov]["delta"]
i_mov += 1
eq_netto.append(r["equity"] - cum_mov)
picco_netto = max(eq_netto)
if picco_netto > 0:
dd = (eq_netto[-1] - picco_netto) / picco_netto
if dd < -0.005: if dd < -0.005:
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). " dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco_netto:,.2f}, "
"al netto dei movimenti di capitale certi). "
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota") "⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
# --- volatilita' implicita contro realizzata --------------------------------------------- # --- volatilita' implicita contro realizzata ---------------------------------------------
+46
View File
@@ -49,6 +49,9 @@ import numpy as np
MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate
MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura
GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO) GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO)
SOGLIA_ORARI_H = 6.0 # (#30) max_age_h <= questa soglia = "sorvegliante orario": i tre
# con max_age 3h (cuscino_watch, venue_watch, collect_chain) oggi,
# derivato dalla tabella e non da una lista di nomi cablata
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -244,3 +247,46 @@ def alerts_from(rows: list[dict]) -> list[str]:
msg += f" | METTE A RISCHIO il gate {r['gate']}" msg += f" | METTE A RISCHIO il gate {r['gate']}"
out.append(msg) out.append(msg)
return out return out
# ===========================================================================
# GIRO ORARIO (#30). Il giro giornaliero (cron_daily) resta com'e': report completo, avviso
# sempre. I sorveglianti a max_age <= 6h (oggi cuscino_watch, venue_watch, collect_chain) si
# scoprono fermi fino a 24h dopo se nessuno li guarda fuori da quel giro: qui si guardano ogni
# ora, ma si notifica solo cio' che e' NUOVO rispetto all'ultimo avviso (niente Telegram a
# raffica sullo stesso guasto fermo). Tutto PURO: il marcatore lo legge/scrive lo script.
# ===========================================================================
def monitors_orari(monitors: tuple[MonitorSpec, ...] = MONITORS,
soglia_h: float = SOGLIA_ORARI_H) -> tuple[MonitorSpec, ...]:
"""I monitor con cadenza di controllo ORARIA: max_age_h <= soglia_h. Derivato dalla tabella
`MONITORS`, non da una lista di nomi cablata: aggiungere un monitor orario lo include qui
senza toccare questa funzione."""
return tuple(m for m in monitors if m.max_age_h <= soglia_h)
def chiave_alert(row: dict) -> str:
"""Chiave stabile di un alert per il marcatore 'gia' detto': nome + stato, NON il testo
(`why` contiene l'eta' dell'ultima barra, che cambia a ogni giro anche se il guasto e' lo
stesso)."""
return f"{row['name']}:{row['status']}"
def alert_nuovi(rows: list[dict], detti: set[str]) -> list[dict]:
"""Le righe-alert (non OK, non NUOVO — vedi `alerts_from`) la cui chiave NON e' nel
marcatore `detti`: quelle davvero nuove, le sole da notificare."""
return [r for r in rows if r["status"] not in ("OK", "NUOVO") and chiave_alert(r) not in detti]
def marcatore_aggiornato(rows: list[dict], detti: set[str]) -> set[str]:
"""Il marcatore dopo questo giro: le chiavi dei monitor ancora in alert restano/si aggiungono,
quelle dei monitor tornati OK si TOLGONO — cosi' un nuovo guasto sullo stesso monitor riparte
con un nuovo avviso invece di restare 'gia' detto' per sempre. Non scrive nulla: lo fa lo
script, e solo se la notifica e' partita (stessa regola di fee_watch #29)."""
out = set(detti)
for r in rows:
chiavi_monitor = {k for k in out if k.startswith(f"{r['name']}:")}
if r["status"] == "OK":
out -= chiavi_monitor
elif r["status"] != "NUOVO":
out.add(chiave_alert(r))
return out
+40 -8
View File
@@ -39,8 +39,16 @@ FINESTRA_GIORNALE_G = 7 # pagine del giornale lette
FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale) FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
FINESTRA_COMMIT_G = 14 # commit letti FINESTRA_COMMIT_G = 14 # commit letti
CARTELLA = Path("docs") / "revisioni" CARTELLA = Path("docs") / "revisioni"
MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo MAX_CHARS_VOCE = 90_000 # il resto delle voci e' molto piu' piccolo
# #37 C12. CLAUDE.md e' arrivato a 89.590 caratteri (99,5% di MAX_CHARS_VOCE, 27/09): al primo
# paragrafo aggiunto si sarebbe troncato. Tetto SUO, piu' largo — il vincolo vero resta
# MAX_CHARS_TOTALE, applicato A PARTE in `materiale()` a prescindere dal tetto di ogni voce: la
# somma dei tetti per-voce (che supera 400k) non e' il limite, lo e' il secondo giro di `_tronca`.
MAX_CHARS_CLAUDE_MD = 150_000
MAX_CHARS_TOTALE = 400_000 MAX_CHARS_TOTALE = 400_000
SOGLIA_AVVISO_TETTO = 0.95 # avviso quando una voce supera questa frazione del SUO tetto
_MARCA_VICINO_TETTO = "[… vicino al tetto:" # marcatore condiviso: materiale() lo scrive, i
# consumatori (prompt, rapporto) lo rileggono
MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito
LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata
TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre
@@ -89,6 +97,12 @@ def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
def _cap_voce(nome: str) -> int:
"""Tetto PER-VOCE: CLAUDE.md ne ha uno suo, piu' largo (#37 C12). Il totale
(MAX_CHARS_TOTALE) resta un vincolo A PARTE, applicato in `materiale()` a valle di questo."""
return MAX_CHARS_CLAUDE_MD if nome.startswith("CLAUDE.md") else MAX_CHARS_VOCE
def _cmd(args: list[str], cwd: Path) -> str: def _cmd(args: list[str], cwd: Path) -> str:
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo» """Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
non e' «va tutto bene»).""" non e' «va tutto bene»)."""
@@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
out, tot = [], 0 out, tot = [], 0
for nome, testo in voci: for nome, testo in voci:
t, tr = _tronca(testo) cap = _cap_voce(nome)
# #37 C12: avviso PRIMA di troncare, quando la voce e' vicina al SUO tetto (non ancora al
# tetto totale, sotto): la nota entra nel testo, cosi' arriva sia al prompt del revisore
# sia — via _MARCA_VICINO_TETTO — al rapporto, come [TRONCATO] oggi. Il margine
# (`+ len(nota) <= cap`) e' voluto: la nota stessa non deve MAI far scattare il
# troncamento che sta segnalando in anticipo.
if len(testo) >= SOGLIA_AVVISO_TETTO * cap:
nota = (f"\n{_MARCA_VICINO_TETTO} {len(testo):,} di {cap:,} caratteri "
f"({100 * len(testo) / cap:.0f}%)]")
if len(testo) + len(nota) <= cap:
testo = testo + nota
t, tr = _tronca(testo, cap)
if tot + len(t) > MAX_CHARS_TOTALE: if tot + len(t) > MAX_CHARS_TOTALE:
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
tot += len(t) tot += len(t)
@@ -186,7 +211,8 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str: def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str:
parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"] parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"]
for nome, testo, tr in voci: for nome, testo, tr in voci:
parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n") tag = " [TRONCATO]" if tr else (" [VICINO AL TETTO]" if _MARCA_VICINO_TETTO in testo else "")
parti.append(f"\n=== {nome}{tag} ===\n{testo}\n")
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.") parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
return "\n".join(parti) return "\n".join(parti)
@@ -236,7 +262,8 @@ def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str
"- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file " "- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
"scritto e' questo", "", "scritto e' questo", "",
"| voce | caratteri | troncata |", "|---|---|---|"] "| voce | caratteri | troncata |", "|---|---|---|"]
coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci] coda += [f"| {n} | {len(t):,} | "
f"{'si' if tr else ('quasi' if _MARCA_VICINO_TETTO in t else '')} |" for n, t, tr in voci]
return "\n".join(testa + corpo + coda) + "\n" return "\n".join(testa + corpo + coda) + "\n"
@@ -258,10 +285,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n" return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>") f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)" sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
corpo = html.escape(sintesi, quote=False)
tagliato = "" tagliato = ""
if len(corpo) > LIMITE_TELEGRAM: # (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]" # un'entita' a meta' (es. "&am") e Telegram rifiuta l'intero messaggio ("can't parse
# entities") invece di accorciarlo. Il marcatore di taglio si aggiunge dopo, fuori dall'escape
# (e' testo nostro, senza `&`/`<`: non ha bisogno di escape).
if len(sintesi) > LIMITE_TELEGRAM:
sintesi, tagliato = sintesi[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
corpo = html.escape(sintesi, quote=False)
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n" return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>") f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
@@ -312,7 +343,8 @@ def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, " print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else "")) f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
for n, t, tr in voci: for n, t, tr in voci:
print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}") tag = " [troncata]" if tr else (" [vicino al tetto]" if _MARCA_VICINO_TETTO in t else "")
print(f" {len(t):>8,} {n}{tag}")
if secco: if secco:
return esito return esito
+20 -7
View File
@@ -95,15 +95,20 @@ def _positions(client):
USDE_INDEX_URL = _usde.index_url() USDE_INDEX_URL = _usde.index_url()
def _collaterale_usde(client) -> tuple[float, str]: def _collaterale_usde(client) -> tuple[float | None, str]:
"""Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che """Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che
PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in
src/live/usde.py — difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC, src/live/usde.py — difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC,
-24% fantasma dopo la prima conversione.""" -24% fantasma dopo la prima conversione.
⚠️ (#20) Un'eccezione sulla LETTURA del conto USDE non e' "0 USDE": e' "non lo so". Ritorna
None (non 0.0) + la nota dell'eccezione -> `_equity` propaga l'ignoto invece di dedurre un
equity piu' basso di quello vero. Eq letta con successo e <= 0 resta 0.0 (conto senza USDE:
quello e' un fatto accertato, legittimo)."""
try: try:
eq = float(client.account_summary("USDE").get("equity") or 0) eq = float(client.account_summary("USDE").get("equity") or 0)
except Exception: except Exception as e:
return 0.0, "" return None, f"USDE illeggibile: {type(e).__name__}: {e}"
if eq <= 0: if eq <= 0:
return 0.0, "" return 0.0, ""
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL) px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL)
@@ -117,6 +122,8 @@ def _equity(client, marks):
eq = float(client.account_summary("USDC").get("equity") or 0) eq = float(client.account_summary("USDC").get("equity") or 0)
if eq > 1: if eq > 1:
usde_usd, usde_note = _collaterale_usde(client) usde_usd, usde_note = _collaterale_usde(client)
if usde_usd is None: # (#20) USDE illeggibile -> l'equity e' IGNOTA,
return None, usde_note # non "USDC senza USDE": non si assume 0.
if usde_usd > 0: if usde_usd > 0:
return eq + usde_usd, f"mainnet USDC + {usde_note}" return eq + usde_usd, f"mainnet USDC + {usde_note}"
return eq, "mainnet USDC" return eq, "mainnet USDC"
@@ -133,7 +140,8 @@ def _equity(client, marks):
if any_ok and tot > 1: if any_ok and tot > 1:
return tot, "mainnet coin-margined" return tot, "mainnet coin-margined"
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B): # real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
# segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside). # segnalato a book_execute, che in modo SOLO PROTEZIONE non manda alcun rebalance_signed —
# controlla comunque il disaster-SL, che dipende da posizione/mark, non da equity (#21).
return None, "conto flat / non finanziato" return None, "conto flat / non finanziato"
@@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
eq_basis = ("override" if equity_override is not None eq_basis = ("override" if equity_override is not None
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)") else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
# Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del # Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del
# conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO. # conto reale. SEGNALATO, e book_execute passa a SOLO PROTEZIONE: nessun rebalance_signed (non
# si dimensiona un ordine senza sapere quanto vale il conto), ma il disaster-SL si controlla
# comunque per ogni asset (#21).
eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} " eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} "
f"invece del conto reale") if (equity_override is None and client is not None f"invece del conto reale") if (equity_override is None and client is not None
and not real_eq) else None and not real_eq) else None
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
return dict( return dict(
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()), last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
online=(client is not None and marks_src.get("BTC") == "mainnet"), # (#25) TUTTI i mark devono venire da mainnet, non solo BTC: con due asset, un solo
# controllo lasciava ETH libero di essere in fallback (close certificata) senza che
# `online` lo dicesse -> il gate offline di book_execute non si accorgeva del guasto.
online=(client is not None and all(marks_src.get(a) == "mainnet" for a in ASSETS)),
real_equity=real_eq, equity=equity, eq_basis=eq_basis, real_equity=real_eq, equity=equity, eq_basis=eq_basis,
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback, pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
assets=assets, orders=orders, live_trades=live_trades, assets=assets, orders=orders, live_trades=live_trades,
+152 -34
View File
@@ -2,20 +2,34 @@
PERCHE' ESISTE PERCHE' ESISTE
-------------- --------------
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della `logs/cron_book.log` e' **gitignored, non nel backup e ruotabile**, ed e' la fonte che `sync()`
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill: usa per popolare il DB: una rotazione lo cancellerebbe senza che nessuno se ne accorga.
19 righe su 19 a `00:00:00`. L'ora vera sta solo in `logs/cron_book.log` — che e' **gitignored,
non nel backup e ruotabile**. Quindi oggi la cronologia reale del libro live vive in un file
che una rotazione cancella, e nessuno se ne accorgerebbe.
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
il perimetro gia' coperto dal backup rotativo della VPS. il perimetro gia' coperto dal backup rotativo della VPS.
⚠️ CORREZIONE (#37 C11, #35 A9). Fino al 2026-08-23 `data/live/book_executions.jsonl` scriveva in
`ts_utc` la data della **barra di segnale** (`pd.Timestamp(r['last_data'])`), non l'ora del fill:
19 righe su 19 a `00:00:00`. Dal 2026-08-24 01:07Z `book_execute.py` (`_registra_fill`) scrive
l'ora VERA del fill (`datetime.now(timezone.utc)` al momento dell'ordine, non piu' la barra),
UNA per fill — con il prezzo a piena precisione (non arrotondato come nella stampa del log) e il
campo `pos_after` (letto dal reconcile subito dopo il rebalance, #39 — None se quella lettura
solleva, o se il passo e' un `RebalanceParziale` a meta'). E' quindi, dal 24/08 in poi, la fonte
con l'ora PIU' precisa per-fill. Dal 2026-09-27 (#40) `sync()` la usa anche per ARRICCHIRE il
DB: `arricchisci_da_jsonl()` gira dopo l'inserimento e prima dei round-trip, e le righe dal 24/08
finiscono con `ts_utc`/`price` dal fill vero e `ts_source='jsonl'` invece che `'cron_log'` (e
`pos_after` quando il jsonl lo porta) — SOLO se l'abbinamento e' univoco, altrimenti resta com'e'
e conta come ambiguo.
FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola
---------------------------------------------------------------------- ----------------------------------------------------------------------
1. `logs/cron_book.log` -> ORA VERA del giro + contesto (equity, tp_frac, skh, target, posizione) 1. `logs/cron_book.log` -> l'ora di INIZIO GIRO (una per run, condivisa da tutti i fill di quel
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. run, non per-fill) + contesto (equity, tp_frac, skh, target,
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora. posizione) e la riga di fill
`-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. FONTE usata da `sync()`.
2. `data/live/book_executions.jsonl` -> gli stessi fill, con l'ora VERA per-fill dal 2026-08-24
01:07Z (prima: la data della barra di segnale, 00:00:00), prezzo a
piena precisione e `pos_after`. Entra SOLO in `reconcile()`.
3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms, 3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms,
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH. **ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
@@ -32,7 +46,7 @@ import json
import re import re
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -42,6 +56,10 @@ EXEC_JSONL = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20). # Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
ARMING = "2026-06-20" ARMING = "2026-06-20"
# Da qui in poi book_executions.jsonl porta l'ora VERA del fill in `ts_utc` (fix #35 A9, docstring
# sopra): soglia per il controllo di conteggio jsonl-vs-DB in scripts/live/trades_db.py:sync()
# (#37 C11). Prima di questo istante il campo e' la barra di segnale, non comparabile.
CUTOFF_TS_VERO = "2026-08-24T01:07:00+00:00"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS fills ( CREATE TABLE IF NOT EXISTS fills (
@@ -262,31 +280,43 @@ def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0.
def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]: def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]:
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty.""" """Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty.
Lotti CON SEGNO (#32 C2): ogni lotto e' long (+1) o short (-1). Un fill chiude PRIMA i
lotti di segno OPPOSTO (FIFO), e l'eccedenza — se resta qty dopo aver chiuso tutto il
lato opposto — apre (o accoda) un lotto nel segno del fill stesso. Prima di questa
riparazione un SELL senza lotti long da chiudere spariva in silenzio (nessun lotto,
nessun round-trip): l'inversione long -> short in un solo fill e' lo stesso meccanismo,
solo che il lato opposto si esaurisce a meta' fill invece che all'inizio.
"""
from collections import deque, defaultdict from collections import deque, defaultdict
lotti: dict[str, deque] = defaultdict(deque) lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno]
rts: list[dict] = [] rts: list[dict] = []
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)): for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
fee_u = f.fee / f.qty if f.qty else 0.0 fee_u = f.fee / f.qty if f.qty else 0.0
if f.side == "buy": segno_fill = 1 if f.side == "buy" else -1
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u]) dq = lotti[f.asset]
else: resto = f.qty
resto = f.qty while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
while resto > 1e-12 and lotti[f.asset]: lq, lp, lts, lfee_u, lseg = dq[0]
lq, lp, lts, lfee_u = lotti[f.asset][0] usa = min(resto, lq)
usa = min(resto, lq) # long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
lordo = usa * (f.price - lp) lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
quota_fee = usa * (lfee_u + fee_u) quota_fee = usa * (lfee_u + fee_u)
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600 ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc, rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo, px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
fee_quota=quota_fee, pnl_netto=lordo - quota_fee)) fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
lq -= usa; resto -= usa direzione=("long" if lseg == 1 else "short")))
if lq <= 1e-12: lq -= usa; resto -= usa
lotti[f.asset].popleft() if lq <= 1e-12:
else: dq.popleft()
lotti[f.asset][0][0] = lq else:
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq} dq[0][0] = lq
if resto > 1e-12: # l'eccedenza apre/accoda nel segno del fill
dq.append([resto, f.price, f.ts_utc, fee_u, segno_fill])
aperti = {a: [dict(qty=q, price=p, ts=t, direzione=("long" if s == 1 else "short"))
for q, p, t, _, s in dq] for a, dq in lotti.items() if dq}
return rts, aperti return rts, aperti
@@ -321,6 +351,86 @@ def upsert_fills(con: sqlite3.Connection, fills: list[Fill]) -> int:
return n return n
def _ts_utc(s) -> datetime | None:
"""Parsa un timestamp ISO in un datetime AWARE utc. None se illeggibile.
Le 19 righe scritte prima del fix #35 A9 (barra di segnale, `pd.Timestamp` in stampa: es.
"2026-07-08 00:00:00", spazio invece di 'T', nessun offset) sono NAIVE — senza questa
normalizzazione, confrontarle con un ts_utc del DB (sempre aware, +00:00) solleva
`TypeError: can't compare offset-naive and offset-aware datetimes` (misurato sui dati veri,
27/09/2026). Sono tutte precedenti a CUTOFF_TS_VERO: qui si limita a non farle esplodere,
non a farle abbinare — restano fuori dalla finestra di qualunque candidato dal 24/08."""
if not s:
return None
try:
ts = datetime.fromisoformat(str(s))
except ValueError:
return None
return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts
def arricchisci_da_jsonl(con: sqlite3.Connection, righe_jsonl: list[dict]) -> tuple[int, int]:
"""Arricchisce le righe `ts_source='cron_log'` con l'ora vera, il prezzo e `pos_after` letti
da `book_executions.jsonl` (#40, TradingBook#11: lo sviluppo lasciato non fatto dal
commento sopra). NON tocca `fill_id`: e' la chiave della riga, calcolata all'INSERT da
ts_utc/asset/side/qty/price ORIGINALI (cron_log) — qui si fa un UPDATE per `fill_id`, che
cambia solo le colonne, mai la chiave con cui la riga e' indicizzata.
Candidate: `ts_source='cron_log'` e `ts_utc >= CUTOFF_TS_VERO` (prima di quella soglia il
jsonl portava la barra di segnale, non l'ora vera — non ha una controparte comparabile).
Abbinamento a una riga jsonl: stesso asset, stesso side, |qty - filled| < 1e-9, e ts jsonl in
[ts_utc DB, ts_utc DB + 15 minuti] (il DB ha l'ora di INIZIO GIRO, il jsonl l'ora del fill,
che viene dopo). Esattamente UN candidato -> UPDATE; zero o piu' di uno -> non si tocca (non
si indovina quale, o se ce n'e' uno) e conta come ambiguo. Un record jsonl assegnato non si
riusa: due fill identici nello stesso giro non finiscono abbinati alla stessa riga.
Idempotente: una riga gia' `ts_source='jsonl'` non e' piu' candidata al giro successivo.
Ritorna (righe_aggiornate, righe_ambigue)."""
candidati = con.execute(
"SELECT fill_id, ts_utc, asset, side, qty FROM fills "
"WHERE ts_source='cron_log' AND ts_utc >= ? ORDER BY ts_utc, asset",
(CUTOFF_TS_VERO,)).fetchall()
usati: set[int] = set() # indici in righe_jsonl gia' assegnati a un fill_id
aggiornate = 0
ambigue = 0
for riga in candidati:
ts_db = _ts_utc(riga["ts_utc"])
if ts_db is None:
ambigue += 1 # ts illeggibile: non e' un abbinamento, e' un dato rotto
continue
fine = ts_db + timedelta(minutes=15)
trovati = []
for i, r in enumerate(righe_jsonl):
if i in usati:
continue
if r.get("asset") != riga["asset"] or r.get("side") != riga["side"]:
continue
filled = r.get("filled")
if filled is None or abs(float(filled) - riga["qty"]) >= 1e-9:
continue
ts_j = _ts_utc(r.get("ts_utc"))
if ts_j is None or not (ts_db <= ts_j <= fine):
continue
trovati.append(i)
if len(trovati) != 1:
ambigue += 1
continue
i = trovati[0]
r = righe_jsonl[i]
usati.add(i)
pos_after = r.get("pos_after")
if pos_after is None:
con.execute("UPDATE fills SET ts_utc=?, ts_source='jsonl', price=? WHERE fill_id=?",
(r["ts_utc"], r["price"], riga["fill_id"]))
else:
con.execute(
"UPDATE fills SET ts_utc=?, ts_source='jsonl', price=?, pos_after=? WHERE fill_id=?",
(r["ts_utc"], r["price"], pos_after, riga["fill_id"]))
aggiornate += 1
con.commit()
return aggiornate, ambigue
def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int: def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int:
n = 0 n = 0
for ts, v, src in punti: for ts, v, src in punti:
@@ -332,8 +442,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]])
def rebuild_roundtrips(con: sqlite3.Connection) -> int: def rebuild_roundtrips(con: sqlite3.Connection) -> int:
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.""" """Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
`qty > 0` (#32 C10): un fill "NON VERIFICATO" da `place_order error` puo' arrivare a
qty 0 — non e' un trade, resta nel DB marcato (`verified=0`) ma fuori dal FIFO.
"""
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"], fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
price=r["price"], fee=r["fee"]) for r in righe] price=r["price"], fee=r["fee"]) for r in righe]
rts, _ = fifo_roundtrips(fills) rts, _ = fifo_roundtrips(fills)
@@ -358,7 +472,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None):
def stato_aperto(con: sqlite3.Connection) -> dict: def stato_aperto(con: sqlite3.Connection) -> dict:
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall() # qty > 0 (#32 C10): stessa esclusione di rebuild_roundtrips, un fill a qty 0 non e' posizione.
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"], fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
price=r["price"], fee=r["fee"]) for r in righe] price=r["price"], fee=r["fee"]) for r in righe]
_, aperti = fifo_roundtrips(fills) _, aperti = fifo_roundtrips(fills)
@@ -366,7 +481,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
for a, lotti in aperti.items(): for a, lotti in aperti.items():
q = sum(x["qty"] for x in lotti) q = sum(x["qty"] for x in lotti)
costo = sum(x["qty"] * x["price"] for x in lotti) costo = sum(x["qty"] * x["price"] for x in lotti)
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti)) # un asset ha un solo segno di lotti aperti alla volta (fifo_roundtrips chiude prima
# il lato opposto): la direzione del primo lotto vale per tutti (#32 C2).
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti),
direzione=lotti[0]["direzione"])
return out return out
+6 -6
View File
@@ -104,12 +104,12 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO # il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
# (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e' # (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e'
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere: # il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
# n_asset x frac x disaster_sl_pct x equity_totale # book.leva_tetto() x disaster_sl_pct x equity_totale
# tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la # tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui, # formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
# e usde_convert pure — due lettori, una formula. # e usde_convert pure — due lettori, una formula. ⚠️ Fino al #28 la leva lorda era RIDICHIARATA
# qui (N_ASSET fisso x WEIGHT, senza scala): a scala 1,25 dava 0,30 invece di 0,375. Ora la stessa
N_ASSET = 2 # `book.leva_tetto` che usa anche il giornale (src/live/journal.py).
def frazione_cuscino() -> tuple[float, str]: def frazione_cuscino() -> tuple[float, str]:
@@ -117,8 +117,8 @@ def frazione_cuscino() -> tuple[float, str]:
cfg = json.loads(CFG_PATH.read_text()) cfg = json.loads(CFG_PATH.read_text())
sl = float(cfg["disaster_sl_pct"]) sl = float(cfg["disaster_sl_pct"])
from src.live import book as B # lazy: book importa shadow, che importa questo modulo from src.live import book as B # lazy: book importa shadow, che importa questo modulo
frac = float(B.WEIGHT) leva, come_leva = B.leva_tetto(cfg)
return N_ASSET * frac * sl, f"{N_ASSET} asset x frac {frac} x SL {sl:.0%}" return leva * sl, f"{come_leva} x SL {sl:.0%}"
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]: def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
+33 -22
View File
@@ -28,19 +28,21 @@ col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per u
se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5). se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5).
La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente. La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente.
⚠️ **LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e' ⚠️ **IL RAMO CHE DECLASSAVA E' MORTO, RIMOSSO DA `diagnose()` (#35 B11, 27/09/2026).**
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA `in_release_window()` diceva se l'orologio cadeva nello slot di release Deribit (martedi'
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che 09:00-09:30 UTC); `diagnose()` la usava per declassare la GRAVITA' di una manutenzione gia'
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un riconosciuta dalla SONDA (REGOLA P9 — *un allarme massimo speso per un evento atteso e' un
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio allarme che non verra' letto il giorno che e' vero*). Ma l'UNICO chiamante di `diagnose()` e'
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di `book_execute.py`, in cron al minuto **:47** di ogni ora (dal 25/08): la finestra 09:00-09:30 non
`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero' contiene mai :47, quindi quel ramo non poteva MAI diventare vero — si leggeva come una
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario, declassazione attiva e non lo e' mai stata. Il campo `atteso` RESTA sul verdetto
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare. (`book_execute.py` lo scrive nel dict del report: cambiare il formato per una pulizia e' un costo
che non vale la pena) ma e' hardcoded a False in `diagnose()`. `in_release_window()` e le
REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il costanti `RELEASE_*` restano invece nel modulo: le usa `test_book_cadenza.py`/`test_revisione.py`
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra per verificare che i cron di book/review evitino lo stesso slot — uno scopo indipendente da
di meno. L'episodio finisce nel log e nella notifica comunque. `diagnose()` e ancora valido. Per declassare di nuovo un evento annunciato in `diagnose()` la
strada e' legarlo alla DURATA dell'annuncio (`venue_news`, se e quando esiste), non all'orologio
di un chiamante che non e' mai sincrono con lo slot.
""" """
from __future__ import annotations from __future__ import annotations
@@ -53,7 +55,7 @@ import requests
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
# --- verdetti ----------------------------------------------------------------------------- # --- verdetti -----------------------------------------------------------------------------
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051
V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione
V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile) V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5) V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
@@ -70,8 +72,12 @@ PROBE_TIMEOUT = 8.0
# 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes"). # 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes").
# ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica # ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica
# dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta # dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta
# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola — # lo slot, questa costante mente in silenzio.
# decide la sonda, e la finestra al massimo declassa. # ⚠️ #35 B11 (27/09/2026): questa finestra NON declassa piu' niente in `diagnose()` (vedi sotto):
# l'unico chiamante gira in cron al minuto :47, che non ci cade mai dentro. La funzione e le
# costanti restano perche' `test_book_cadenza.py`/`test_revisione.py` le usano per un controllo
# diverso e ancora valido: che i cron di `book`/`review` cadano FUORI da questo slot (P1, la
# stessa finestra, non una seconda dichiarata a mano).
RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi' RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
RELEASE_START_MIN = 9 * 60 # 09:00 UTC RELEASE_START_MIN = 9 * 60 # 09:00 UTC
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia. RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
@@ -79,7 +85,8 @@ RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione to
@dataclass(frozen=True) @dataclass(frozen=True)
class Diagnosi: class Diagnosi:
"""Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9).""" """Il verdetto (*cosa*) e la spiegazione (*perche'*). `atteso` resta nel formato ma e' sempre
False (#35 B11): l'unico chiamante non cade mai nella finestra che l'avrebbe declassato."""
verdetto: str verdetto: str
perche: str perche: str
atteso: bool atteso: bool
@@ -87,7 +94,8 @@ class Diagnosi:
@property @property
def gravita(self) -> str: def gravita(self) -> str:
"""Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9).""" """Emoji del titolo di notifica. `atteso` e' sempre False (#35 B11): ramo morto lasciato
per compatibilita' di formato, non per una declassazione che avviene ancora."""
if self.atteso: if self.atteso:
return "ℹ️" return "ℹ️"
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️") return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
@@ -106,8 +114,9 @@ class Diagnosi:
def in_release_window(now: datetime | None = None) -> bool: def in_release_window(now: datetime | None = None) -> bool:
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)? """L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo. PURA e testabile. Non declassa piu' niente in `diagnose()` (#35 B11): resta per chi verifica
""" che un cron (book, review) sia schedulato fuori da questo slot (P1: la finestra si deriva da
qui, non si riscrive una seconda volta nel test)."""
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
now = now.astimezone(timezone.utc) now = now.astimezone(timezone.utc)
if now.weekday() != RELEASE_WEEKDAY: if now.weekday() != RELEASE_WEEKDAY:
@@ -136,7 +145,6 @@ def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]:
def diagnose(errori_osservati: list[str] | None = None, def diagnose(errori_osservati: list[str] | None = None,
now: datetime | None = None,
sonda=None) -> Diagnosi: sonda=None) -> Diagnosi:
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete. """Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
@@ -169,7 +177,10 @@ def diagnose(errori_osservati: list[str] | None = None,
verdetto = V_VENUE_GIU verdetto = V_VENUE_GIU
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione" perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
atteso = verdetto == V_MANUTENZIONE and in_release_window(now) # #35 B11: qui c'era `verdetto == V_MANUTENZIONE and in_release_window(now)`. L'unico
# chiamante (book_execute.py, cron :47) non cade mai nella finestra 09:00-09:30 di martedi':
# il ramo non poteva mai diventare vero. Il campo resta (formato del report), sempre False.
atteso = False
prova = "; ".join([*errori[:3], grezzo])[:500] prova = "; ".join([*errori[:3], grezzo])[:500]
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova) return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
+5
View File
@@ -346,6 +346,11 @@ def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})") f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()): if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
report["severity"] = "alert" report["severity"] = "alert"
# issue #31: senza ALERT, un asset BLIND lasciava severity=None — il titolo del runner
# cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per un episodio che e'
# "non vedo", non "stress misurato". BLIND e' lo stato MORBIDO: cede il passo ad ALERT.
elif report["levels"] and any(l == "BLIND" for l in report["levels"].values()):
report["severity"] = "blind"
# --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati # --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False)) lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
+9 -3
View File
@@ -400,8 +400,14 @@ def deribit_book_sleeves() -> list[Sleeve]:
e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step). e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step).
Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento
periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py). periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py).
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).""" TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).
I pesi si DERIVANO da src.live.book (P1): due letterali qui erano una seconda dichiarazione,
libera di divergere dal book vero (#35 B12). Import locale: book importa questo modulo
(_skyhook_positions) a livello di modulo -> un import in testa qui creerebbe un ciclo (come
fa usde.py con book)."""
from src.live import book as B # lazy: vedi nota sul ciclo qui sopra
return [ return [
tp01_sleeve(weight=0.75), tp01_sleeve(weight=B.W_TP01),
skyhook_sleeve(weight=0.25), skyhook_sleeve(weight=B.W_SKH),
] ]
+63
View File
@@ -210,6 +210,21 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok" assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
def test_secco_stampa_e_non_salva(db, monkeypatch, tmp_path, capsys):
"""#34: prima del fix `J.salva()` veniva chiamato comunque, prima del controllo su
`secco` — un giro "a secco" scriveva lo stesso le sezioni misurate nel DB."""
import analista as CLI
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi che non deve finire nel DB.", None))
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
g = date(2026, 8, 21)
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB vuoto in partenza
stato = CLI.scrivi(db, g, modello="finto", secco=True, verbose=False, telegram=False)
assert stato == "ok"
capsys.readouterr() # non e' l'oggetto del test
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB ANCORA vuoto
assert not (tmp_path / f"{g}.md").exists() # scrivi_file non chiamato
def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path): def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path):
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi.""" """Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
import analista as CLI import analista as CLI
@@ -270,6 +285,39 @@ def test_la_testata_porta_i_numeri_che_si_leggono_dalla_notifica():
t = A.testata(_voce_piena()) t = A.testata(_voce_piena())
assert "$637.36" in t and "+2.36" in t and "+39.30" in t assert "$637.36" in t and "+2.36" in t and "+39.30" in t
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: non e' "oggi", e' "nel giorno".
assert "nel giorno" in t and "oggi" not in t
def test_testata_con_movimento_mostra_trading_giorno_non_il_grezzo():
"""(#35 C8) Un versamento di $1.399 non deve leggersi come P&L: con mov_giorno != 0 e
trading_giorno calcolabile, la cifra mostrata e' trading_giorno, non delta_equity."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=2.36)
t = A.testata(voce)
assert "+2.36 nel giorno" in t
assert "di cui +1399.00 movimenti" in t
assert "+1401.36" not in t # il grezzo non compare piu' in testata
def test_testata_con_movimento_ma_trading_giorno_none_resta_sul_grezzo():
"""(#35 C8) Se trading_giorno non e' calcolabile (None) si resta su delta_equity, ma con
l'etichetta nuova, non "oggi"."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=None)
t = A.testata(voce)
assert "+1401.36 nel giorno" in t
assert "di cui" not in t
def test_testata_senza_movimento_mostra_il_grezzo_con_etichetta_nuova():
"""(#35 C8) mov_giorno assente o 0: nessun movimento da scorporare, stesso trattamento di
journal.scorporo (bool())."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], mov_giorno=0.0, trading_giorno=2.36)
t = A.testata(voce)
assert "+2.36 nel giorno" in t
assert "di cui" not in t
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto(): def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
@@ -297,6 +345,21 @@ def test_il_taglio_a_4096_si_DICHIARA():
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
def test_taglio_a_cavallo_di_entita_e_tag_resta_HTML_valido():
"""(#35 C13) Il taglio e' sul GREZZO, prima di _b/html.escape e prima di **-><b>: un '&' o
un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata
(es. '&am') ne' un <b> senza chiusura -- Telegram rifiuta l'INTERO messaggio altrimenti
("can't parse entities"). Il pattern si ripete per tutta la lunghezza, cosi' il punto di
taglio (qualunque sia) cade sempre dentro o a cavallo di un '&' o di '**...**'."""
pattern = "parola & altra parola **in grassetto** ancora testo "
analisi = pattern * 200 # ben oltre qualunque budget qui
m = A.per_telegram(_voce_piena(), analisi, "ok", [])
assert "tagliato" in m
assert re.search(r"&(?!amp;|lt;|gt;)", m) is None # nessuna entita' spezzata o '&' nudo
assert m.count("<b>") == m.count("</b>") # ogni <b> aperto e' chiuso
assert m.count("<i>") == m.count("</i>")
def test_un_messaggio_normale_non_viene_toccato(): def test_un_messaggio_normale_non_viene_toccato():
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", []) m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
assert "tagliato" not in m and m.endswith("Analisi breve.") assert "tagliato" not in m and m.endswith("Analisi breve.")
+65 -13
View File
@@ -150,6 +150,27 @@ def test_weights_match_backtest_sleeves():
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12 assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
def test_deribit_book_sleeves_deriva_i_pesi_da_book_non_li_ridichiara():
"""#35 B12 — P1: stesso pattern di test_r0909_libro_nei_crolli.test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia.
`test_weights_match_backtest_sleeves` sopra passerebbe anche con due letterali per coincidenza
di valore (0.75/0.25 riscritti a mano): qui si verifica l'IDENTITA' con B.W_TP01/B.W_SKH e
che il letterale non sia piu' nella fonte di `deribit_book_sleeves`."""
import inspect
import re
import src.live.book as B
from src.portfolio.sleeves import deribit_book_sleeves
pesi = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
assert pesi["TP01"] is B.W_TP01 or pesi["TP01"] == B.W_TP01
assert pesi["SKH01"] is B.W_SKH or pesi["SKH01"] == B.W_SKH
src = inspect.getsource(deribit_book_sleeves)
assert "from src.live import book" in src, "i pesi devono derivare da src.live.book (P1)"
assert not re.search(r"weight\s*=\s*0\.75", src), "peso TP01 ancora cablato"
assert not re.search(r"weight\s*=\s*0\.25", src), "peso SKH01 ancora cablato"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Costruzione ordine: side, reduce_only, flip, close, soglia minima. # Costruzione ordine: side, reduce_only, flip, close, soglia minima.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -415,7 +436,10 @@ 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 SEMPRE (anche in dry-run); quando il giro e'
ARMATO l'alert parte UNA sola volta, nel modo SOLO PROTEZIONE (#22) — niente flat silenzioso,
ma nemmeno due notify per lo stesso guasto (il vecchio notify incondizionato e' stato tolto,
U6 #22)."""
from helpers import carica_script from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py") mod = carica_script("scripts/live/book_execute.py")
@@ -426,17 +450,27 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)], skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
orders=[], orders=[],
) )
class _TraderOk:
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
alerts = [] alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned) monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det))) monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
monkeypatch.setattr(mod, "load_config", monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30)) lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
mod._run() try:
mod._run()
except SystemExit:
pass
out = capsys.readouterr().out out = capsys.readouterr().out
assert "SKH FEED ERRORE" in out # stampato nel log assert "SKH FEED ERRORE" in out # stampato nel log, sempre
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato assert len(alerts) == 1 # UNA sola notify, non due
assert "solo protezione" in alerts[0][0].lower()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -535,8 +569,11 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP" assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP"
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys): def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error).""" """(#21, U2) Comportamento cambiato: eq_fallback NON fa piu' proseguire un rebalance sul
paper_cap — passa a SOLO PROTEZIONE (nessun ordine, disaster-SL comunque controllato, exit 2).
Prima di questa riparazione il giro dimensionava un ordine reale su un capitale fittizio da
$2.000 quando non si sapeva quanto valesse il conto vero; ora non manda alcun ordine."""
from helpers import carica_script from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py") mod = carica_script("scripts/live/book_execute.py")
@@ -551,18 +588,33 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
alerts = [] alerts = []
class FakeDT: class FakeDT:
def __init__(self):
self.rebalance_chiamato = False
def rebalance_signed(self, *a, **k):
self.rebalance_chiamato = True
return []
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"} def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
def position_usd(self, inst): return 0.0 def position_usd(self, inst): return 0.0
trader = FakeDT()
monkeypatch.setattr(mod, "book_report", lambda **k: canned) monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det))) monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
monkeypatch.setattr(mod, "DeribitTrader", lambda: FakeDT()) monkeypatch.setattr(mod, "DeribitTrader", lambda: trader)
monkeypatch.setattr(mod, "load_config", monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30)) lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run() uscita = None
try:
mod._run()
except SystemExit as e:
uscita = e.code
out = capsys.readouterr().out out = capsys.readouterr().out
assert "EQUITY FALLBACK" in out # avvisato assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta)
assert any("equity fallback" in t for t, _ in alerts) # alert inviato # (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)"
assert "Nessuna azione" in out # HA PROSEGUITO (non halt) # e' stato tolto perche' diceva il falso (qui non si dimensiona alcun ordine) — resta solo
# quello del modo SOLO PROTEZIONE.
assert len(alerts) == 1
assert "solo protezione" in alerts[0][0].lower()
assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato
assert uscita == 2 # giro degradato
+639
View File
@@ -0,0 +1,639 @@
"""Test U2 (gitea:Adriano/PythagorasGoal #20, #21, #22, #23, #25).
Principio unico: quando il giro non sa QUANTO vale il conto (#20, #21) o QUALE e' il segnale
(#22), NON manda ordini di ribilancio, ma CONTROLLA LO STESSO il disaster-SL di ogni asset — la
protezione dipende da posizione e mark, non da equity o segnale. E un fill gia' eseguito non e'
un fill perso se un passo successivo del rebalance, o la lettura della posizione dopo, falliscono
(#23). `online` richiede TUTTI i mark da mainnet, non solo BTC (#25).
Nessun test tocca la rete: trader ed eccezioni sono finti.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from helpers import carica_script
from src.live import shadow as S
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, RebalanceParziale
def _fresh_bar() -> str:
import pandas as _pd
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
# ---------------------------------------------------------------------------
# #20 — USDE illeggibile: l'equity diventa IGNOTA (None), non 0.
# ---------------------------------------------------------------------------
class _ClientUSDEBoom:
def __init__(self, usdc=2056.0):
self._usdc = usdc
def account_summary(self, cur):
if cur == "USDE":
raise RuntimeError("gateway giu'")
return {"equity": self._usdc}
def test_usde_illeggibile_rende_equity_ignota_non_zero():
eq, src = S._equity(_ClientUSDEBoom(), {})
assert eq is None
assert "USDE illeggibile" in src and "RuntimeError" in src
def test_watermark_non_si_scrive_su_equity_ignota(tmp_path, monkeypatch):
"""`write_equity_watermark` riceve None quando l'equity e' ignota: non deve scrivere il
file (gia' cosi' nel codice esistente — qui lo si blinda con un test, come chiesto)."""
import src.live.book as book
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
assert book.write_equity_watermark(None) is None
assert not (tmp_path / "equity_seen.json").exists()
# ---------------------------------------------------------------------------
# #25 — online richiede TUTTI i mark mainnet, non solo BTC.
# ---------------------------------------------------------------------------
def test_online_richiede_tutti_i_mark_mainnet(monkeypatch):
monkeypatch.setattr(S, "_safe_client", lambda: object())
monkeypatch.setattr(S, "_marks", lambda c, d: (
{"BTC": 60000.0, "ETH": 3000.0},
{"BTC": "mainnet", "ETH": "fallback close (RuntimeError)"}))
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
r = S.shadow_report()
assert r["online"] is False # prima di #25 sarebbe stato True (guardava solo BTC)
def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch):
monkeypatch.setattr(S, "_safe_client", lambda: object())
monkeypatch.setattr(S, "_marks", lambda c, d: (
{"BTC": 60000.0, "ETH": 3000.0}, {"BTC": "mainnet", "ETH": "mainnet"}))
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
assert S.shadow_report()["online"] is True
# ---------------------------------------------------------------------------
# #21, #22 — SOLO PROTEZIONE: equity ignota o SKH ignoto -> nessun rebalance_signed, ma il
# disaster-SL si controlla LO STESSO per ogni asset. Giro degradato -> exit 2 (se ARMATO).
# ---------------------------------------------------------------------------
def _asset(nome, inst, mark, order=None):
return dict(asset=nome, instrument=inst, tp_frac=1.0, skh_sign=1, skh_state="flat",
net_target=225.0, position_usd=0.0, mark=mark, order=order)
def _canned_report(**overrides):
base = dict(
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None,
eq_fallback=None,
assets=[
_asset("BTC", "BTC_USDC-PERPETUAL", 60000.0,
order=dict(side="buy", delta=225.0, is_close=False, needs_flip=False)),
_asset("ETH", "ETH_USDC-PERPETUAL", 3000.0, order=None),
],
orders=[],
)
base.update(overrides)
return base
class _TraderSoloProtezione:
"""Trader finto: registra se rebalance_signed viene chiamato (NON deve) e quali asset
ricevono ensure_disaster_sl (TUTTI, comunque)."""
def __init__(self):
self.rebalance_chiamato = False
self.visti_sl: list[str] = []
def rebalance_signed(self, *a, **k):
self.rebalance_chiamato = True
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
self.visti_sl.append(inst)
return {"state": "ok", "stop": 1.0, "amount": 0.0}
def _run_solo_protezione(canned, monkeypatch, capsys):
mod = carica_script("scripts/live/book_execute.py")
trader = _TraderSoloProtezione()
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
uscita = None
try:
mod._run()
except SystemExit as e:
uscita = e.code
out = capsys.readouterr().out
return trader, alerts, uscita, out
def test_eq_fallback_e_solo_protezione(monkeypatch, capsys):
canned = _canned_report(
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000")
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
assert trader.rebalance_chiamato is False # nessun ordine dimensionato
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI
# (#21) UNA sola notify per il guasto, non due: il vecchio testo diceva "sizing su paper_cap",
# falso in modo SOLO PROTEZIONE (nessun ordine dimensionato affatto).
assert len(alerts) == 1
assert "solo protezione" in alerts[0][0].lower()
assert "paper_cap" not in alerts[0][0]
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out # riepilogo finale corretto
assert uscita == 2
def test_skh_error_e_solo_protezione(monkeypatch, capsys):
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
assert trader.rebalance_chiamato is False
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"]
# (#22) UNA sola notify, non due ("SKH feed fallito" + "SKH non calcolato").
assert len(alerts) == 1
assert "solo protezione" in alerts[0][0].lower()
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out
assert uscita == 2
def test_solo_protezione_in_dry_run_non_esce_2(monkeypatch, capsys):
"""Un'anteprima (senza execution_enabled) non ha tentato nulla: non e' un giro degradato."""
mod = carica_script("scripts/live/book_execute.py")
canned = _canned_report(eq_fallback="equity reale non leggibile -> sizing su paper_cap")
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=False, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute
mod._run() # non deve sollevare
assert "solo protezione" in capsys.readouterr().out.lower()
# ---------------------------------------------------------------------------
# #23 — fill persi: un fill gia' eseguito non sparisce se un passo successivo del rebalance
# (flip) o la lettura della posizione dopo falliscono.
# ---------------------------------------------------------------------------
class _FakeExec(DeribitTrader):
"""DeribitTrader senza rete: instrumenta close/open per il test del flip parziale."""
def __init__(self, pos_usd, open_boom=None, chiusura_fill=None):
self._pos = pos_usd
self._open_boom = open_boom
self._chiusura_fill = chiusura_fill
def position_usd(self, instrument):
return self._pos
def close(self, instrument, label="tp01-close"):
return self._chiusura_fill
def open(self, instrument, side, amount, label="tp01-open"):
if self._open_boom is not None:
raise self._open_boom
return Fill(instrument, side, amount, amount, 60000.0, 0.01, "oid", "filled", True, "")
def test_flip_con_open_che_esplode_dopo_close_solleva_rebalance_parziale():
"""execution.py (#23a): il close del flip e' andato, l'open dall'altra parte no -> il fill
del close NON deve sparire dentro un'eccezione nuda."""
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
"filled", True, "")
t = _FakeExec(pos_usd=120.0, open_boom=RuntimeError("502 Bad Gateway"), chiusura_fill=close_fill)
with pytest.raises(RebalanceParziale) as ei:
t.rebalance_signed("BTC_USDC-PERPETUAL", -80.0, 60000.0, min_usd=5.0)
assert ei.value.fills == [close_fill]
assert isinstance(ei.value.causa, RuntimeError) and "502" in str(ei.value.causa)
def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkeypatch, capsys):
"""book_execute (#23b): su RebalanceParziale, il fill del close finisce nel jsonl e nella
riga di log PRIMA che la causa venga rilanciata; l'asset risulta fallito, l'altro asset
(ETH) resta elaborato normalmente."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
"filled", True, "")
class _TraderFlip:
def __init__(self):
self.visti_sl: list[str] = []
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
if inst.startswith("BTC"):
raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close"))
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
self.visti_sl.append(inst)
return {"state": "ok", "stop": 1.0, "amount": 0.0}
trader = _TraderFlip()
canned = _canned_report() # BTC ha un ordine, ETH order=None
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
uscita = None
try:
mod._run()
except SystemExit as e:
uscita = e.code
out = capsys.readouterr().out
assert uscita == 2
assert "-> SELL 0.0020 @ $60,000.0" in out # riga di fill stampata
assert "asset falliti in questo giro: BTC" in out # isolamento esistente
assert trader.visti_sl == ["ETH_USDC-PERPETUAL"] # BTC salta l'SL questo giro
# (il fill del close NON e' un fill perso: e' nel jsonl anche se il resto del flip e' fallito)
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["side"] == "sell" and righe[0]["filled"] == 0.002
assert righe[0]["pos_after"] is None
def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monkeypatch, capsys):
"""book_execute (#23b): rebalance_signed va a segno, ma `position_usd` (reconcile) solleva
dopo -> il fill e' comunque registrato con pos_after None, e non e' un guasto del giro."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderReconcileBoom:
def __init__(self):
self.visti_sl: list[str] = []
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill] if inst.startswith("BTC") else []
def position_usd(self, inst):
raise RuntimeError("api 500")
def ensure_disaster_sl(self, inst, sl_pct):
self.visti_sl.append(inst)
return {"state": "ok", "stop": 1.0, "amount": 0.0}
trader = _TraderReconcileBoom()
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run() # non deve sollevare: il reconcile mancato non e' fatale
out = capsys.readouterr().out
assert "reconcile: pos illeggibile (RuntimeError)" in out
assert "asset falliti" not in out # NON e' trattato come giro fallito
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione comunque
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None
# ---------------------------------------------------------------------------
# #39 — regressione di #23: pos_after finiva sempre None nel jsonl, anche quando il reconcile
# leggeva la posizione senza problemi (la lettura era rimasta un side-effect informativo, il suo
# risultato non arrivava piu' a `_registra_fill`).
# ---------------------------------------------------------------------------
def test_rebalance_ok_scrive_pos_after_dal_reconcile(tmp_path, monkeypatch, capsys):
"""book_execute (#39): rebalance_signed e position_usd vanno a segno entrambi -> il record
di log_event ha pos_after = il valore letto dal reconcile, non None."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderOk:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill] if inst.startswith("BTC") else []
def position_usd(self, inst):
return 500.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run()
out = capsys.readouterr().out
assert "reconcile: pos $500" in out
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] == 500.0
def test_position_usd_che_solleva_non_impedisce_il_fill_pos_after_none(tmp_path, monkeypatch, capsys):
"""book_execute (#39): stesso scenario di sopra ma il reconcile solleva -> il fill si
registra comunque (vincolo #23), con pos_after None."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderReconcileBoom:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill] if inst.startswith("BTC") else []
def position_usd(self, inst):
raise RuntimeError("api 500")
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderReconcileBoom())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run() # non deve sollevare: il fill non e' perso
out = capsys.readouterr().out
assert "reconcile: pos illeggibile (RuntimeError)" in out
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["filled"] == 0.003
assert righe[0]["pos_after"] is None
# ---------------------------------------------------------------------------
# Il formato delle righe stampate non e' cambiato: tradesdb.parse_cron_log deve continuare a
# leggerlo, sia in un giro normale sia in un giro con RebalanceParziale (#23).
# ---------------------------------------------------------------------------
def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypatch, capsys, tmp_path):
from src.live import tradesdb as T
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill_btc = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderOk:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill_btc] if inst.startswith("BTC") else []
def position_usd(self, inst):
return 180.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run()
out = capsys.readouterr().out
log = f"===== 2026-09-27T13:47:01Z cron_book =====\n{out}===== done 13:47:05Z =====\n"
runs = T.parse_cron_log(log)
assert len(runs) == 1 and len(runs[0].fills) == 1
f = runs[0].fills[0]
assert f.asset == "BTC" and f.side == "buy" and abs(f.qty - 0.003) < 1e-9 and f.verified == 1
def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsys, tmp_path):
from src.live import tradesdb as T
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
"filled", True, "")
class _TraderFlip:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
if inst.startswith("BTC"):
raise RebalanceParziale([close_fill], RuntimeError("open KO"))
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderFlip())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
try:
mod._run()
except SystemExit:
pass
out = capsys.readouterr().out
log = f"===== 2026-09-27T14:47:01Z cron_book =====\n{out}===== done 14:47:05Z =====\n"
runs = T.parse_cron_log(log)
assert len(runs) == 1 and len(runs[0].fills) == 1
f = runs[0].fills[0]
assert f.asset == "BTC" and f.side == "sell" and abs(f.qty - 0.002) < 1e-9
# ---------------------------------------------------------------------------
# set_open_cap (#18): chiamato SEMPRE, diretto (l'hasattr era solo per il periodo prima del
# merge con U1, in cui DeribitTrader non lo aveva ancora — tolto qui).
# ---------------------------------------------------------------------------
def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
mod = carica_script("scripts/live/book_execute.py")
class _TraderConCap:
def __init__(self):
self.cap_visti = []
def set_open_cap(self, inst, cap_usd, mark):
self.cap_visti.append((inst, cap_usd, mark))
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
trader = _TraderConCap()
canned = _canned_report(cap_per_asset=300.0, scala=1.25)
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run()
assert trader.cap_visti == [("BTC_USDC-PERPETUAL", 300.0 * 1.25, 60000.0)] # solo BTC ha un ordine
def test_trader_senza_set_open_cap_fallisce_isolato_non_lascia_morire_il_giro(monkeypatch, capsys):
"""Post-merge: `set_open_cap` si chiama diretto, senza hasattr (era solo transitorio prima
del merge con U1). Un trader che non lo espone ora fa fallire QUELL'asset (isolamento per
asset, gia' esistente) — non l'intero giro: ETH resta elaborato normalmente."""
mod = carica_script("scripts/live/book_execute.py")
class _TraderSenzaCap:
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report() # BTC ha un ordine (set_open_cap si chiama), ETH order=None
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
uscita = None
try:
mod._run()
except SystemExit as e:
uscita = e.code
out = capsys.readouterr().out
assert "AttributeError" in out # isolato: si vede nel log, non muore il giro
assert "asset falliti in questo giro: BTC" in out
assert uscita == 2
# ---------------------------------------------------------------------------
# #24 — ensure_disaster_sl "unknown" in book_execute: NON e' "naked" (non si e' tolto nulla),
# ma il giro resta degradato -> notify dedicata e exit 2.
# ---------------------------------------------------------------------------
def test_ensure_disaster_sl_unknown_notifica_e_degrada_il_giro(monkeypatch, capsys):
mod = carica_script("scripts/live/book_execute.py")
class _TraderUnknown:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return []
def position_usd(self, inst):
return 0.0
def ensure_disaster_sl(self, inst, sl_pct):
if inst.startswith("ETH"):
return {"state": "unknown", "notes": "RuntimeError: 502 su get_positions"}
return {"state": "ok", "stop": 1.0, "amount": 0.0}
trader = _TraderUnknown()
canned = _canned_report() # BTC ha un ordine, ETH order=None -> ETH e' l'asset "unknown"
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
uscita = None
try:
mod._run()
except SystemExit as e:
uscita = e.code
out = capsys.readouterr().out
assert uscita == 2
assert "ETH: protezione NON VERIFICATA" in out
notifiche = [d for t, d in alerts if "non verificabile" in t.lower()]
assert len(notifiche) == 1
assert notifiche[0]["asset"] == "ETH"
# ---------------------------------------------------------------------------
# execution.py::_submit (#21) — legge il tetto con getattr: un DeribitTrader creato senza
# passare da __init__ (object.__new__, come qui) non deve rompersi con AttributeError perche'
# gli manca l'attributo _open_cap.
# ---------------------------------------------------------------------------
def test_submit_su_trader_senza_open_cap_non_solleva_attributeerror(monkeypatch):
t = object.__new__(DeribitTrader) # bypassa __init__: niente self._open_cap
monkeypatch.setattr(t, "_post", lambda path, payload: {
"order": {"order_id": "1", "order_state": "filled",
"filled_amount": payload["amount"]},
"trades": [{"price": 1.0, "amount": payload["amount"], "fee": 0.0}]})
dentro = MAX_AMOUNT["ETH_USDC-PERPETUAL"] * 0.5
f = t._submit("ETH_USDC-PERPETUAL", "buy", dentro, reduce_only=False, label="book-open")
assert f.verified is True # arrivato in fondo: niente AttributeError sul cap
+60 -1
View File
@@ -155,7 +155,15 @@ def test_un_tentativo_ogni_sei_ore():
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0] assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
def _finto_lettore_quota(valori):
"""Ritorna i valori dati in ordine, uno per chiamata (per monkeypatch di _leggi_quota_usde)."""
it = iter(valori)
return lambda: next(it)
def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
# rc==0: "ok" a prescindere dalla quota (lo script vero ha gia' le sue guardie, P15) — la
# quota qui non si muove, per mostrare che il caso "ok" non dipende dalla rilettura.
chiamate = [] chiamate = []
class R: class R:
@@ -166,10 +174,61 @@ def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
return R() return R()
monkeypatch.setattr(W.subprocess, "run", finto_run) monkeypatch.setattr(W.subprocess, "run", finto_run)
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.67, 0.67]))
e = W.riconverti(0.67) e = W.riconverti(0.67)
assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py") assert e["ok"] and e["esito"] == "ok" and chiamate and chiamate[0][1].endswith("usde_convert.py")
assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0] assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
assert e["coda"][-1].startswith(" ESITO") assert e["coda"][-1].startswith(" ESITO")
assert e["quota_prima"] == 0.67 and e["quota_dopo"] == 0.67
def test_riconverti_rc_diverso_da_zero_con_quota_mossa_e_parziale(monkeypatch):
# #35 B10: rc!=0 (es. ordine parzialmente fillato poi rifiutato) ma la quota si e' mossa
# per davvero -> "parziale", non "fallita": la vendita/acquisto e' avvenuto, solo non finito.
class R:
returncode, stdout, stderr = 1, "", "book illeggibile a meta' piano\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.70]))
e = W.riconverti(0.64)
assert e["esito"] == "parziale" and e["ok"] is False
assert e["quota_prima"] == 0.75 and e["quota_dopo"] == 0.70
def test_riconverti_rc_diverso_da_zero_con_quota_ferma_e_fallita(monkeypatch):
class R:
returncode, stdout, stderr = 1, "", "prezzo fuori banda: niente ordini\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.751]))
e = W.riconverti(0.64)
assert e["esito"] == "fallita" and e["ok"] is False
def test_riconverti_timeout_con_quota_mossa_e_parziale(monkeypatch):
# #35 B10: il subprocess viene ucciso a TIMEOUT_CONVERT_S ma alcuni ordini erano gia' fillati
# prima del kill -> oggi (ok=rc==0) diventava "FALLITA", corretto e' "parziale".
def finto_run(cmd, **kw):
raise W.subprocess.TimeoutExpired(cmd, kw.get("timeout"))
monkeypatch.setattr(W.subprocess, "run", finto_run)
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.80, 0.72]))
e = W.riconverti(0.64)
assert e["rc"] is None and e["esito"] == "parziale" and e["ok"] is False
assert e["coda"][0].startswith("timeout dopo")
def test_riconverti_rilettura_dopo_fallita_e_fallita_esplicita(monkeypatch):
# se non si riesce a rileggere la quota dopo, non si puo' dire "parziale": si dichiara
# l'incertezza invece di dedurre un movimento zero che non si e' verificato.
class R:
returncode, stdout, stderr = 1, "", "errore\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, None]))
e = W.riconverti(0.64)
assert e["esito"] == "fallita (quota dopo non leggibile)" and e["ok"] is False
assert e["quota_dopo"] is None
def test_il_monitor_e_registrato_in_monitor_health(): def test_il_monitor_e_registrato_in_monitor_health():
+224
View File
@@ -0,0 +1,224 @@
"""Tre riparazioni sul percorso con soldi veri (unita' U1, 2026-09-27), provate contro il codice
che dovrebbe farle — non discusse.
#17. `DeribitRead.position_usd` leggeva `size`/`size_currency` in ASSOLUTO e ignorava
`direction`: uno short risultava un long, e close()/ensure_disaster_sl decidevano lato
ordine e prezzo dello stop dal segno sbagliato.
#18. Tetto per ordine d'apertura: `MAX_AMOUNT` era un tetto FISSO uguale per tutti i conti;
ora il libro deriva il proprio tetto dal cap di config via `set_open_cap()`.
#24. `DeribitRead.open_orders` ingoiava gli errori di ENTRAMBE le chiamate ('all' e
'trigger_all') e ritornava []: un bracket disaster-SL perso passava per "nessun bracket".
Ora solleva, e `ensure_disaster_sl` distingue lo stato "unknown" (letture fallite, non si
cancella ne' si piazza) da "naked" (protezione tolta e non rimessa).
Nessun test tocca la rete.
"""
from __future__ import annotations
import pytest
from src.live.deribit import DeribitRead
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError
ETH = "ETH_USDC-PERPETUAL"
# =================================================================================================
# #17 — position_usd: il segno viene da `direction`, non assunto dal solo `size`.
# =================================================================================================
class _FakeRead(DeribitRead):
"""DeribitRead senza rete: `positions()` finto, per esercitare `position_usd` con la forma
REALE del gateway (size in assoluto + direction a parte)."""
def __init__(self, raw: list[dict]) -> None:
self._raw = raw
def positions(self, currency):
return self._raw
def test_position_usd_sell_e_negativo():
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
assert r.position_usd(ETH) == -560.0
def test_position_usd_buy_e_positivo():
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "buy"}])
assert r.position_usd(ETH) == 560.0
def test_position_usd_senza_direction_si_fida_del_segno_di_size():
"""Compatibile con un gateway che un giorno mandasse gia' il segno in `size`."""
r = _FakeRead([{"instrument_name": ETH, "size": -3}])
assert r.position_usd(ETH) == -3.0
# =================================================================================================
# Verifica a valle di #17: close() e ensure_disaster_sl() decidono lato/stop da `pos > 0`.
# Si mocka `positions()` (dati grezzi del gateway), NON `position_usd()`: cosi' si esercita
# davvero la pipeline positions() -> position_usd() -> close()/ensure_disaster_sl().
# =================================================================================================
class _FakeTraderShort(DeribitTrader):
"""DeribitTrader senza rete: posizione SHORT letta come il gateway la manda davvero, mark
fisso, `_submit` registra la chiamata invece di piazzare l'ordine."""
def __init__(self, raw_positions: list[dict], mark: float = 2694.0) -> None:
self._open_cap: dict[str, float] = {}
self._raw = raw_positions
self._mark = mark
self.submitted: list[dict] = []
self.cancelled: list[str] = []
def positions(self, currency):
return self._raw
def mark_price(self, instrument):
return self._mark
def open_orders(self, instrument):
return []
def cancel_order(self, order_id):
self.cancelled.append(order_id)
return {}
def _submit(self, instrument, side, amount, *, reduce_only, label,
order_type="market", price=None):
self.submitted.append(dict(instrument=instrument, side=side, amount=amount,
reduce_only=reduce_only, label=label,
order_type=order_type, price=price))
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
fee_usdc=0.0, order_id="1", state="filled", verified=True)
def _short_560():
return _FakeTraderShort([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
def test_close_su_short_usa_side_buy_reduce_only():
t = _short_560()
f = t.close(ETH)
assert f is not None
assert len(t.submitted) == 1
call = t.submitted[0]
assert call["side"] == "buy" and call["reduce_only"] is True
assert call["label"] == "tp01-close" and call["amount"] > 0
def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy():
t = _short_560()
ds = t.ensure_disaster_sl(ETH, 0.30)
assert ds["state"] == "placed"
assert len(t.submitted) == 1
call = t.submitted[0]
assert call["side"] == "buy"
assert call["order_type"] == "stop_market"
assert call["price"] > t._mark # stop SOPRA il mark per uno short
# =================================================================================================
# #18 — set_open_cap(): tetto di apertura per strumento, derivato dal cap di config.
# =================================================================================================
class _FakeTraderCap(DeribitTrader):
"""DeribitTrader senza rete: `_post` risponde con un ordine fillato finto, cosi' la parte di
_submit DOPO il guardrail (che qui non ci interessa) non tocca la rete."""
def __init__(self) -> None:
self._open_cap: dict[str, float] = {}
def _post(self, path, payload):
amt = payload["amount"]
return {"order": {"order_id": "1", "order_state": "filled", "filled_amount": amt},
"trades": [{"price": 1.0, "amount": amt, "fee": 0.0}]}
def test_set_open_cap_valida_gli_argomenti():
t = _FakeTraderCap()
with pytest.raises(ValueError):
t.set_open_cap(ETH, 0.0, 2694.0)
with pytest.raises(ValueError):
t.set_open_cap(ETH, 2291.5, 0.0)
def test_set_open_cap_apertura_entro_il_tetto_non_solleva():
"""Equity 4583, cap/asset 2291.5, ETH mark 2694 -> apertura di 0,2807 ETH e' ben dentro il
tetto (1,10 * 2291.5 / 2694 ~= 0,9355 ETH)."""
t = _FakeTraderCap()
t.set_open_cap(ETH, 2291.5, 2694.0)
t._submit(ETH, "buy", 0.2807, reduce_only=False, label="book-open") # non deve solllevare
def test_set_open_cap_3x_il_tetto_solleva_guardrail():
t = _FakeTraderCap()
t.set_open_cap(ETH, 2291.5, 2694.0)
cap = t._open_cap[ETH]
with pytest.raises(GuardrailError):
t._submit(ETH, "buy", cap * 3, reduce_only=False, label="book-open")
def test_senza_set_open_cap_resta_il_default_max_amount():
t = _FakeTraderCap()
dentro = MAX_AMOUNT[ETH] * 0.5
t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare
with pytest.raises(GuardrailError):
t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open")
# =================================================================================================
# #24 — open_orders non ingoia gli errori; ensure_disaster_sl diventa "unknown", non a caso.
# =================================================================================================
class _FakeOpenOrders(DeribitRead):
"""DeribitRead senza rete: 'all' risponde vuoto, 'trigger_all' solleva — replica un bracket
disaster-SL perso su un solo dei due tipi di chiamata."""
def __init__(self) -> None:
pass # niente token, niente rete
def _post(self, path, payload):
if payload.get("type") == "trigger_all":
raise RuntimeError("502 Server Error: Bad Gateway")
return {"result": []}
def test_open_orders_trigger_all_solleva_runtime_error():
r = _FakeOpenOrders()
with pytest.raises(RuntimeError, match="open_orders illeggibile"):
r.open_orders(ETH)
class _FakeTraderOpenOrdersRompe(DeribitTrader):
"""position_usd legge una posizione REALE, ma open_orders solleva: ensure_disaster_sl non
deve decidere su meta' dei dati."""
def __init__(self) -> None:
self._open_cap: dict[str, float] = {}
self.submitted: list[dict] = []
self.cancelled: list[str] = []
def positions(self, currency):
return [{"instrument_name": ETH, "size": 560, "direction": "sell"}]
def mark_price(self, instrument):
return 2694.0
def open_orders(self, instrument):
raise RuntimeError("open_orders illeggibile: RuntimeError: 502 Server Error: Bad Gateway")
def cancel_order(self, order_id):
self.cancelled.append(order_id)
return {}
def _submit(self, instrument, side, amount, *, reduce_only, label,
order_type="market", price=None):
self.submitted.append(dict(side=side, reduce_only=reduce_only))
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
fee_usdc=0.0, order_id="1", state="filled", verified=True)
def test_ensure_disaster_sl_con_open_orders_che_solleva_diventa_unknown_senza_agire():
t = _FakeTraderOpenOrdersRompe()
ds = t.ensure_disaster_sl(ETH, 0.30)
assert ds["state"] == "unknown"
assert "502" in ds["notes"]
assert t.cancelled == [] # nessuna cancel
assert t.submitted == [] # nessun piazzamento
+147
View File
@@ -162,3 +162,150 @@ def test_un_fill_non_misurabile_e_None_non_zero():
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
# fee nulla e' invece una misura legittima (ordine maker a rebate zero) # fee nulla e' invece una misura legittima (ordine maker a rebate zero)
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0 assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
# ===========================================================================
# issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito
# ===========================================================================
# Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario
# falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio
# sarebbe sparito senza essere mai stato detto.
_RIS_AZIONE = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)},
changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0,
level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False,
)
_RIS_OK_SENZA_CAMBI = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False,
)
def _prepara(monkeypatch, risultato, esito_invio):
salvato, inviato = [], []
monkeypatch.setattr(FW, "run", lambda: risultato)
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {})
return salvato, inviato
def test_invio_fallito_non_salva_lo_stato(monkeypatch):
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False)
rc = FW.main()
assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio"
assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio"
assert rc == 2
def test_invio_riuscito_salva_lo_stato(monkeypatch):
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
FW.main()
assert len(inviato) == 1
# (#37 B9) lo stato salvato porta anche il marcatore "divergenza_detta" (qui False: la
# fixture non ha divergenze), sibling delle fee per-strumento, non piu' solo `current`.
assert len(salvato) == 1
assert salvato[0] == {**_RIS_AZIONE["current"], "divergenza_detta": False}
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
"""Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify."""
salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False)
FW.main()
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
assert len(salvato) == 1
# ===========================================================================
# issue #37 (B9) — realized_fee_bps() SEMPRE calcolato (non solo nel blocco di stampa) e
# divergenza dal tier pubblico notificata UNA volta per episodio.
# ===========================================================================
def test_run_calcola_sempre_la_fee_realizzata(monkeypatch):
"""Prima del #37 la fee realizzata girava solo dentro `if not quiet or interessante`: in
cron (--quiet) non girava mai. Ora e' `run()` a calcolarla, sempre."""
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
ins = FW.INSTRUMENTS[0]
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 7.5})
r = FW.run()
assert r["realized"] == {ins: 7.5}
assert len(r["divergenze"]) == 1 and ins in r["divergenze"][0]
assert r["divergenza_gia_detta"] is False
def test_run_nessun_trade_e_None_non_zero(monkeypatch):
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {})
r = FW.run()
assert r["realized"] is None
assert r["divergenze"] == []
def test_run_sotto_soglia_non_e_divergenza(monkeypatch):
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
ins = FW.INSTRUMENTS[0]
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 5.3}) # +0.3 < 1.0
r = FW.run()
assert r["divergenze"] == []
_RIS_DIVERGE = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
realized={"BTC_USDC-PERPETUAL": 7.5},
divergenze=["BTC_USDC-PERPETUAL: realizzata 7.50 bps vs tier pubblico 5.00 (+2.50)"],
divergenza_gia_detta=False,
)
_RIS_DIVERGE_RIPETUTA = dict(_RIS_DIVERGE, divergenza_gia_detta=True)
_RIS_DIVERGE_RIENTRATA = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
realized={"BTC_USDC-PERPETUAL": 5.3}, divergenze=[], divergenza_gia_detta=True,
)
def _prepara_b9(monkeypatch, risultato, esito_invio):
"""Come `_prepara`, ma senza `changes`/livello AZIONE: isola il notify di B9 da quello di
B8 (qui non richiesto, dato che tutte le fixture hanno level='OK' e changes=[])."""
salvato, inviato = [], []
monkeypatch.setattr(FW, "run", lambda: risultato)
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
return salvato, inviato
def test_divergenza_prima_volta_notifica_e_marca(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=True)
FW.main()
assert len(inviato) == 1
assert salvato[-1]["divergenza_detta"] is True
def test_divergenza_stesso_episodio_non_ripete(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIPETUTA, esito_invio=True)
FW.main()
assert inviato == [], "gia' detta in questo episodio: nessun secondo invio"
assert salvato[-1]["divergenza_detta"] is True
def test_divergenza_notify_fallita_non_marca(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=False)
FW.main()
assert len(inviato) == 1
assert salvato[-1]["divergenza_detta"] is False, \
"invio fallito -> marcatore non scritto, il giro dopo ritenta"
def test_divergenza_rientrata_azzera_il_marcatore(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIENTRATA, esito_invio=False)
FW.main()
assert inviato == [], "sotto soglia: nessun invio da tentare"
assert salvato[-1]["divergenza_detta"] is False
+8 -4
View File
@@ -31,12 +31,16 @@ def db(tmp_path):
def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch): def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch):
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore.""" """24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
oggi = datetime.now(timezone.utc).date() class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1, @classmethod
feed_skh_min=0)) def now(cls, tz=None):
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
monkeypatch.setattr(J, "datetime", _OraFissa)
oggi = _OraFissa.now(timezone.utc).date()
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
s = J.salute(db, oggi) s = J.salute(db, oggi)
assert s["giorno_in_corso"] is True assert s["giorno_in_corso"] is True
assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1 assert s["atteso_giri"] == 15 # ore 14:50, il giro delle 14 (:47) e' scattato
assert s["giri_mancanti"] == 0 assert s["giri_mancanti"] == 0
+207
View File
@@ -0,0 +1,207 @@
"""Test nuovi per l'unita' U4 (issue #27, #35 C9/C15/C7) — journal.py.
Non ripete la suite esistente: la tocca solo dove il comportamento CAMBIA.
"""
from __future__ import annotations
import json
import sys
from datetime import date, 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 journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=0))
def _libro(**asset):
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
azione="HOLD (a target)"), **v) for k, v in asset.items()}
lordo = sum(abs(v["posizione"]) for v in a.values())
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
def _pnl(**kw):
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
cumulato_da_arming=0.0, letture_equity=24)
return {**base, **kw}
def _ids(righe):
return [r["id"] for r in righe]
def _serie_equity(con, punti):
for ts, eq in punti:
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test"))
con.commit()
def _dichiara(monkeypatch, tmp_path, righe):
f = tmp_path / "mov.jsonl"
f.write_text("\n".join(json.dumps(r) for r in righe) + "\n")
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
return f
# ============================================================================================
# #27 — due dichiarati nello stesso intervallo si SOMMANO, non si sovrascrivono
# ============================================================================================
def test_due_dichiarati_nello_stesso_intervallo_si_sommano(db, monkeypatch, tmp_path):
"""+25 e +30 dichiarati nello stesso intervallo, trading reale +5: prima del fix il
secondo dichiarato sovrascriveva il primo (delta finale 30, non 55)."""
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 1000.0),
("2026-08-25T08:07:01+00:00", 1060.0)]) # +60 = 25+30 mov. +5 trading
_dichiara(monkeypatch, tmp_path, [
dict(ts_utc="2026-08-25T07:20:00+00:00", delta_usd=25.0, nota="primo versamento"),
dict(ts_utc="2026-08-25T07:40:00+00:00", delta_usd=30.0, nota="secondo versamento")])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert len(r["eventi"]) == 1 # UN evento, non due
e = r["eventi"][0]
assert e["fonte"] == "dichiarato" and e["delta"] == pytest.approx(55.0) # 25+30, non 30
assert e["dopo"] == pytest.approx(1055.0)
assert "primo versamento" in e["dichiarazione"] and "secondo versamento" in e["dichiarazione"]
assert r["certi"] == pytest.approx(55.0)
assert r["trading"] == pytest.approx(5.0) # 1060-1000-55
# solo due letture: l'intero intervallo e' il movimento, nessun segmento di trading — 0%
# e' il limite DICHIARATO di rendimento_twr ("nessun segmento = nessun tempo a mercato"),
# non un valore inventato.
assert r["twr"] == pytest.approx(0.0)
def test_dichiarato_su_rilevato_resta_come_prima_anche_con_un_altro_dichiarato_altrove(db, monkeypatch, tmp_path):
"""Non regressione: l'aggancio dichiarato+rilevato non deve rompersi perche' ora si guarda
anche ad altri dichiarati."""
monkeypatch.setattr(J, "_px_1h", lambda a: __import__("pandas").Series(
100.0, index=__import__("pandas").date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")))
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
("2026-08-25T11:47:01+00:00", 2066.88),
("2026-08-25T12:47:01+00:00", 2064.56)])
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
nota="bonifico")])
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
assert len(r["eventi"]) == 1
e = r["eventi"][0]
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
# ============================================================================================
# #35 C9 — il giro dell'ora corrente conta solo dopo che e' scattato (minuto :47 + margine)
# ============================================================================================
def test_atteso_giri_al_minuto_10_non_conta_ancora_il_giro_dell_ora_corrente(db, monkeypatch):
class _OraFissa(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 8, 25, 14, 10, tzinfo=timezone.utc)
monkeypatch.setattr(J, "datetime", _OraFissa)
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=14, feed_skh_min=0))
s = J.salute(db, date(2026, 8, 25))
assert s["atteso_giri"] == 14 # il giro delle 14 (:47) non e' ancora scattato
assert s["giri_mancanti"] == 0
def test_atteso_giri_al_minuto_50_conta_il_giro_dell_ora_corrente(db, monkeypatch):
class _OraFissa(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
monkeypatch.setattr(J, "datetime", _OraFissa)
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
s = J.salute(db, date(2026, 8, 25))
assert s["atteso_giri"] == 15 # dopo le :47 il giro delle 14 e' scattato
assert s["giri_mancanti"] == 0
# ============================================================================================
# #35 C15 — "ultimi 7 giorni" deve coprire 7 giorni di calendario, non 8
# ============================================================================================
def test_la_finestra_di_concentrazione_e_di_7_giorni_non_8(db):
base = date(2026, 6, 24) # base .. base+13 = 14 giorni prima del giorno
righe = []
for i in range(7): # giorni 0..6: piatto
d = (base + timedelta(days=i)).isoformat()
righe += [(f"{d}T06:00:00+00:00", 1000.0), (f"{d}T18:00:00+00:00", 1000.0)]
d7 = (base + timedelta(days=7)).isoformat() # giorno-7: il salto avviene QUI, a meta' giornata
righe += [(f"{d7}T06:00:00+00:00", 1000.0), (f"{d7}T18:00:00+00:00", 1020.0)]
for i in range(8, 14): # giorni 8..13: piatto al nuovo livello
d = (base + timedelta(days=i)).isoformat()
righe += [(f"{d}T06:00:00+00:00", 1020.0), (f"{d}T18:00:00+00:00", 1020.0)]
d14 = (base + timedelta(days=14)).isoformat() # il giorno della voce
righe += [(f"{d14}T06:00:00+00:00", 1020.0), (f"{d14}T18:00:00+00:00", 1100.0)]
_serie_equity(db, righe)
assert len(righe) == 30 # >24: la sezione si attiva
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[]), giorno=d14)
out = J.lettura(db, v)
riga = next(r for r in out if r["id"] == "concentrazione")
# baseline corretta = ULTIMA lettura di giorno-7 (1020, alle 18:00), non la PRIMA (1000,
# alle 06:00): (1100-1020)/(1100-1000) = 80%. Con la finestra sbagliata (8 giorni di
# calendario) sarebbe (1100-1000)/100 = 100%.
assert "80%" in riga["testo"]
assert "100%" not in riga["testo"]
# ============================================================================================
# #35 C7 — il drawdown si legge sulla serie AL NETTO dei movimenti certi
# ============================================================================================
def test_un_prelievo_non_diventa_un_drawdown_di_trading(db):
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
punti = [base + timedelta(hours=i) for i in range(26)] # 26 letture, >24
for t in punti[:-1]:
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), 1000.0, "t"))
db.execute("INSERT INTO equity VALUES (?,?,?)", (punti[-1].isoformat(), 800.0, "t"))
db.commit()
mov = dict(classe="movimento", delta=-200.0, ts_prima=punti[-2].isoformat(),
ts_dopo=punti[-1].isoformat(), prima=1000.0, dopo=800.0, pct=-0.2,
fonte="dichiarato", dichiarazione="prelievo di prova")
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
giorno=punti[-1].date().isoformat())
out = J.lettura(db, v)
# sulla serie grezza (picco 1000, ultima 800) sarebbe -20%: un prelievo, non una perdita.
assert "drawdown" not in _ids(out)
def test_un_versamento_non_nasconde_un_drawdown_di_trading_vero(db):
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
letture = [(base + timedelta(hours=i), 1000.0) for i in range(20)] # piatto, 20 letture
letture.append((base + timedelta(hours=20), 1500.0)) # versamento +500
for h, v in ((21, 1490.0), (22, 1480.0), (23, 1475.0), (24, 1470.0)): # trading in calo
letture.append((base + timedelta(hours=h), v))
for t, val in letture:
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), val, "t"))
db.commit()
assert len(letture) == 25 # >24
mov = dict(classe="movimento", delta=500.0, ts_prima=letture[19][0].isoformat(),
ts_dopo=letture[20][0].isoformat(), prima=1000.0, dopo=1500.0, pct=0.5,
fonte="dichiarato", dichiarazione="versamento di prova")
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
giorno=letture[-1][0].date().isoformat())
out = J.lettura(db, v)
riga = next(r for r in out if r["id"] == "drawdown")
# netto: picco 1000 (prima/al versamento), ultima 970 (1470-500) -> -3.00%. Sulla serie
# grezza (picco 1500 gonfiato dal versamento) sarebbe -2.00%: sottostima il vero calo.
assert "-3.00%" in riga["testo"]
assert "-2.00%" not in riga["testo"]
+140
View File
@@ -0,0 +1,140 @@
"""book.leva_tetto — la leva lorda massima del libro, in UNA sola formula (#28).
Prima della correzione la leva lorda tornava con tre formule diverse: `usde.frazione_cuscino`
(N_ASSET fisso, senza scala), la regola `leva` del giornale (senza scala) e
`journal.movimenti_capitale` (con scala, ma ridichiarata). A scala 1,25 il cuscino USDC
risultava 0,30 dell'equity invece di 0,375 — il caso che T2 riproduce.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import book as B # noqa: E402
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
from src.live import usde as U # noqa: E402
def _cfg(**kw) -> dict:
base = dict(max_notional_per_asset_frac=0.5, disaster_sl_pct=0.3)
base.update(kw)
return base
# ---------------------------------------------------------------- T1: scala assente
def test_T1_scala_assente_e_frac_uguale_al_weight():
leva, come = B.leva_tetto(_cfg())
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
assert "scala 1.0" in come
# ---------------------------------------------------------------- T2: il caso dell'issue
def test_T2_scala_1_25_alza_la_leva_e_il_cuscino_usde(tmp_path, monkeypatch):
"""A scala 1,25 il cuscino USDC deve valere 0,375 dell'equity, non 0,30 (#28)."""
cfg = _cfg(book_scale_k=1.25)
leva, _ = B.leva_tetto(cfg)
assert leva == pytest.approx(1.25)
# frazione_cuscino legge da CFG_PATH: si monkeypatcha il file, come fanno gli altri test
# su usde/_scala (grep "CFG_PATH\|CONFIG" tests/).
cpath = tmp_path / "live.json"
cpath.write_text(json.dumps(cfg))
monkeypatch.setattr(U, "CFG_PATH", cpath)
f, come = U.frazione_cuscino()
assert f == pytest.approx(0.375)
assert "SL 30%" in come
# ---------------------------------------------------------------- T3: fuori scaletta non solleva
def test_T3_scala_fuori_scaletta_non_solleva_e_torna_il_tetto_di_codice():
"""cuscino_watch non deve morire proprio quando la config e' sbagliata (verifica fable B5)."""
leva, come = B.leva_tetto(_cfg(book_scale_k=1.1))
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
assert "tetto di codice" in come
# ---------------------------------------------------------------- T4: frac assente
def test_T4_frac_assente_usa_weight():
cfg = dict(disaster_sl_pct=0.3) # niente max_notional_per_asset_frac
leva, come = B.leva_tetto(cfg)
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
assert f"frac {B.WEIGHT}" in come
# ---------------------------------------------------------------- T5: il tronco al tetto di codice
def test_T5_leva_oltre_il_tetto_di_codice_viene_troncata(monkeypatch):
"""Verifica diretta del troncamento. Con WEIGHT alzato il prodotto supera LEVA_LORDA_MAX pur
restando `_scala` silenziosa (frac ASSENTE dalla config -> il suo controllo sul prodotto non
scatta, perche' guarda solo `max_notional_per_asset_frac` quando e' dichiarato)."""
monkeypatch.setattr(B, "WEIGHT", 0.9)
leva, come = B.leva_tetto(dict(book_scale_k=1.25)) # frac assente -> usa WEIGHT = 0.9
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
assert "troncato" in come
# ---------------------------------------------------------------- T6: il giornale usa la stessa leva_tetto
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=0))
def _libro(**asset):
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
azione="HOLD (a target)"), **v) for k, v in asset.items()}
lordo = sum(abs(v["posizione"]) for v in a.values())
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
def _pnl(**kw):
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
cumulato_da_arming=0.0, letture_equity=24)
return {**base, **kw}
def test_T6_regola_leva_margine_positivo_a_scala_1_25(db, monkeypatch, tmp_path):
"""lv=1.2 su un tetto di 1.25x (frac 0.5, scala 1.25): margine positivo, non negativo."""
(tmp_path / "config").mkdir()
(tmp_path / "config" / "live.json").write_text(
json.dumps(dict(max_notional_per_asset_frac=0.5, book_scale_k=1.25)))
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path)
lb = _libro(BTC=dict(posizione=360.0), ETH=dict(posizione=360.0)) # 720/600 = 1.2x
assert lb["leva_lorda"] == pytest.approx(1.2)
r = J.lettura(db, _voce(libro=lb, pnl=_pnl()))
riga = next(x for x in r if x["id"] == "leva")
assert "1.25x" in riga["testo"] and "margine 0.05x" in riga["testo"]
# ---------------------------------------------------------------- A7 (#35): min_order_usd da config
def test_A7_build_book_order_rispetta_min_usd_di_config():
"""book_report legge min_order_usd da config (default 5.0 se assente, come book_execute.py).
Qui si verifica direttamente build_book_order, che e' il punto dove il valore viene usato:
con min_usd=20 un delta di $10 non genera ordine, con min_usd=5 (il default di oggi) si."""
order_stretto = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=20.0)
assert order_stretto is None # |delta|=10 < 20: sotto soglia col tetto alzato
order_default = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=5.0)
assert order_default is not None # |delta|=10 >= 5: lo stesso caso, oggi, genera ordine
+140
View File
@@ -0,0 +1,140 @@
"""Test del giro ORARIO di monitor_health (#30).
I sorveglianti a max_age<=6h (cuscino_watch, venue_watch, collect_chain) girano alle 00:30 col
report giornaliero e basta: un guasto si scopre fino a 24h dopo. Qui si guarda ogni ora, ma si
notifica solo cio' che e' NUOVO rispetto al marcatore "gia' detto" — altrimenti un monitor fermo
manderebbe lo stesso Telegram ogni ora per giorni.
Le funzioni PURE (filtro per max_age, chiave dell'alert, chi e' nuovo, il marcatore aggiornato)
stanno in src/live/monitor_health.py e si testano su righe finte, senza toccare disco o rete.
L'orchestrazione (leggere/scrivere il marcatore, chiamare notify) sta nello script e si testa qui
sotto con monkeypatch — NIENTE Telegram vero."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live.monitor_health import ( # noqa: E402
MonitorSpec,
alert_nuovi,
chiave_alert,
marcatore_aggiornato,
monitors_orari,
)
import monitor_health as MH # noqa: E402 (scripts/live/monitor_health.py)
# righe finte, nella forma che assess()/check_all() restituiscono davvero
ROW_FERMO = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=3, age_h=4.0,
coverage=0.9, expected=3, status="FERMO", why="ultima barra 4h fa (limite 3h)")
ROW_OK = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=10, age_h=0.2,
coverage=1.0, expected=10, status="OK", why="")
ROW_NUOVO = dict(name="venue_watch", gate=None, n_bars=1, age_h=0.1, coverage=None,
expected=None, status="NUOVO", why="1 barra: non c'e' abbastanza serie")
# ===========================================================================
# funzioni PURE — filtro per max_age (src/live/monitor_health.py)
# ===========================================================================
def test_il_filtro_deriva_dalla_tabella_non_da_una_lista_di_nomi():
specs = (
MonitorSpec("a", "a", "runs.jsonl", 1.0, max_age_h=3.0),
MonitorSpec("b", "b", "runs.jsonl", 1.0, max_age_h=6.0), # al bordo: dentro
MonitorSpec("c", "c", "runs.jsonl", 24.0, max_age_h=30.0),
)
assert {m.name for m in monitors_orari(specs)} == {"a", "b"}
def test_i_tre_orari_di_oggi_sono_quelli_noti():
"""Se la tabella cambia questo test lo dice: e' l'ancora fra il registro e il flag."""
assert {m.name for m in monitors_orari()} == {"collect_chain", "cuscino_watch", "venue_watch"}
# ===========================================================================
# funzioni PURE — chiave, nuovi, marcatore
# ===========================================================================
def test_chiave_e_nome_piu_stato_non_il_testo():
"""Il testo (`why`) contiene l'eta', che cambia ogni ora anche se il guasto e' lo stesso."""
assert chiave_alert(ROW_FERMO) == "venue_watch:FERMO"
piu_vecchio = {**ROW_FERMO, "age_h": 9.0, "why": "ultima barra 9h fa (limite 3h)"}
assert chiave_alert(piu_vecchio) == chiave_alert(ROW_FERMO)
def test_alert_nuovo_se_la_chiave_non_e_nel_marcatore():
assert alert_nuovi([ROW_FERMO], set()) == [ROW_FERMO]
def test_lo_stesso_alert_al_secondo_giro_non_e_piu_nuovo():
assert alert_nuovi([ROW_FERMO], {"venue_watch:FERMO"}) == []
def test_nuovo_non_e_un_alert_da_notificare():
assert alert_nuovi([ROW_NUOVO], set()) == []
def test_il_marcatore_aggiunge_la_chiave_di_un_alert():
assert marcatore_aggiornato([ROW_FERMO], set()) == {"venue_watch:FERMO"}
def test_il_ritorno_a_ok_toglie_la_chiave_dal_marcatore():
"""Cosi' un nuovo guasto sullo stesso monitor riparte con un nuovo avviso."""
assert marcatore_aggiornato([ROW_OK], {"venue_watch:FERMO"}) == set()
# ===========================================================================
# orchestrazione — scripts/live/monitor_health.py main_orari(), NIENTE Telegram
# ===========================================================================
def test_notify_fallito_non_scrive_il_marcatore(tmp_path, monkeypatch):
"""Stessa regola di fee_watch #29: niente 'gia' detto' su un invio fallito, altrimenti il
prossimo giro non ritenterebbe."""
detto = tmp_path / "detto.json"
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append((a, k)), False)[1])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
rc = MH.main_orari()
assert rc == 2 # c'e' un alert
assert len(chiamate) == 1 # e si e' provato a notificarlo
assert not detto.exists() # ma l'invio e' fallito: nessun marcatore
def test_notify_riuscito_scrive_il_marcatore_e_non_rinotifica(tmp_path, monkeypatch):
detto = tmp_path / "detto.json"
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
monkeypatch.setattr(MH, "notify", lambda *a, **k: True)
rc1 = MH.main_orari()
assert rc1 == 2
assert json.loads(detto.read_text())["detti"] == ["venue_watch:FERMO"]
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
rc2 = MH.main_orari()
assert rc2 == 2 # l'alert c'e' ancora (anche se gia' detto: exit 2 comunque)
assert chiamate == [] # ma non si rimanda lo stesso Telegram
def test_tornato_ok_il_giro_dopo_pulisce_il_marcatore(tmp_path, monkeypatch):
detto = tmp_path / "detto.json"
detto.write_text(json.dumps({"detti": ["venue_watch:FERMO"], "ts": "2026-09-27T00:00:00Z"}))
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_OK])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
rc = MH.main_orari()
assert rc == 0
assert chiamate == [] # OK non notifica mai
assert json.loads(detto.read_text())["detti"] == []
+64 -1
View File
@@ -68,14 +68,65 @@ def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path):
def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch): def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch):
"""CLAUDE.md e' la prima voce e ha un tetto SUO (MAX_CHARS_CLAUDE_MD, #37 C12): si monkeypatcha
quello, non il MAX_CHARS_VOCE generico, che non lo tocca piu'."""
r = _radice(tmp_path) r = _radice(tmp_path)
(r / "CLAUDE.md").write_text("x" * 1000) (r / "CLAUDE.md").write_text("x" * 1000)
monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100) monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 100)
n, t, tr = R.materiale(r, ADESSO)[0] n, t, tr = R.materiale(r, ADESSO)[0]
assert tr and "[… troncato a 100 caratteri su 1,000]" in t assert tr and "[… troncato a 100 caratteri su 1,000]" in t
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO) assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
def test_claude_md_ha_un_tetto_suo_distinto_dal_generico(tmp_path, monkeypatch):
"""#37 C12: CLAUDE.md sfiora MAX_CHARS_VOCE con un margine minimo — deve avere un tetto suo
piu' largo, cosi' un piccolo aggiornamento non lo tronca."""
r = _radice(tmp_path)
n_chars = R.MAX_CHARS_VOCE + 1_000 # sopra il generico, ma sotto MAX_CHARS_CLAUDE_MD
assert n_chars < R.MAX_CHARS_CLAUDE_MD
(r / "CLAUDE.md").write_text("x" * n_chars)
n, t, tr = R.materiale(r, ADESSO)[0]
assert n.startswith("CLAUDE.md")
assert not tr, "sotto il tetto SUO (150k): non deve troncare, anche se supera i 90k generici"
def test_voce_vicina_al_tetto_e_avvisata_nel_prompt_e_nel_rapporto(tmp_path, monkeypatch):
"""#37 C12: al 95% del tetto (non ancora troncata) l'avviso entra nel testo, e da li' arriva
sia al prompt del revisore sia alla tabella del rapporto — come [TRONCATO] oggi."""
r = _radice(tmp_path)
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 10_000)
(r / "CLAUDE.md").write_text("x" * 9_600) # 96% di 10.000, margine largo per la nota
n, t, tr = R.materiale(r, ADESSO)[0]
assert not tr, "non e' troncata: e' vicina"
assert R._MARCA_VICINO_TETTO in t
assert "[VICINO AL TETTO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
rap = R.rapporto("revisione", None, "ok", [], "claude-fable-5-1", ADESSO, [(n, t, tr)])
assert "| quasi |" in rap
def test_voce_lontana_dal_tetto_non_e_avvisata(tmp_path):
voci = R.materiale(_radice(tmp_path), ADESSO)
claude_md = [t for n, t, tr in voci if n.startswith("CLAUDE.md")][0]
assert R._MARCA_VICINO_TETTO not in claude_md
def test_il_totale_resta_sotto_il_tetto_anche_se_la_somma_dei_tetti_lo_supera(tmp_path, monkeypatch):
"""#37 C12: CLAUDE.md (150k) + il resto delle voci (90k l'una) puo' superare facilmente i 400k
di MAX_CHARS_TOTALE in somma — il tetto totale e' applicato A PARTE, a valle, e resta il vero
vincolo: non deve MAI sfondare, qualunque sia il tetto di ogni singola voce."""
r = _radice(tmp_path)
(r / "CLAUDE.md").write_text("x" * R.MAX_CHARS_CLAUDE_MD) # al SUO tetto: 150k
j = r / "docs" / "journal"
for i in range(1, 10): # sovrascrive i giornali della finestra
g = (ADESSO - timedelta(days=i)).date().isoformat()
(j / f"{g}.md").write_text("y" * R.MAX_CHARS_VOCE) # ognuno al SUO tetto: 90k
voci = R.materiale(r, ADESSO)
tot = sum(len(t) for _, t, _ in voci)
somma_tetti = sum(R._cap_voce(n) for n, _, _ in voci)
assert somma_tetti > R.MAX_CHARS_TOTALE, "il caso e' quello che sfonderebbe se il totale non fosse a parte"
assert tot <= R.MAX_CHARS_TOTALE
def test_il_prompt_porta_le_regole_e_la_data(tmp_path): def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO) p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
@@ -132,6 +183,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096 assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
def test_taglio_a_cavallo_di_una_entita_resta_HTML_valido():
"""(#35 C13) Un '&' esattamente al taglio non deve spezzarsi in un'entita' a meta' (es.
'&am'): il taglio e' sul GREZZO, prima di html.escape, quindi il carattere e' incluso o
escluso per intero — mai spezzato a meta' entita'."""
sintesi = "a" * (R.LIMITE_TELEGRAM - 1) + "&resto che verra' tagliato via di sicuro"
tg = R.per_telegram(_revisione(sintesi=sintesi), None, "ok", ADESSO, Path("p.md"))
assert "[… sintesi tagliata" in tg
corpo = tg.split("\n\n", 1)[1].split("\n[…", 1)[0]
assert corpo == "a" * (R.LIMITE_TELEGRAM - 1) + "&amp;" # il '&' e' incluso PER INTERO
assert "&am;" not in tg and "resto che verra'" not in tg # niente entita' a meta', niente coda
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no # ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
def _albero(root: Path) -> set[str]: def _albero(root: Path) -> set[str]:
+8 -2
View File
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
assert "non leggibile" in src assert "non leggibile" in src
def test_gateway_usde_giu_non_rompe_la_lettura_usdc(): def test_gateway_usde_giu_rende_lequity_IGNOTA_non_solo_usdc():
"""(#20) Comportamento cambiato: un'eccezione sulla lettura del conto USDE NON vale 0 USDE.
Prima di questa riparazione, `eq` restava $2056 (solo USDC) come se l'USDE non ci fosse:
se sul conto c'era davvero collaterale USDE, quel $2056 era un equity sottostimato spacciato
per certo. Ora l'intera equity e' IGNOTA (None) — coerente col trattamento di ogni altra
lettura fallita nel modulo (posizione, mark): non si assume, si segnala."""
eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {}) eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {})
assert eq == 2056.0 and src == "mainnet USDC" assert eq is None
assert "USDE illeggibile" in src and "RuntimeError" in src
+101 -1
View File
@@ -7,6 +7,7 @@ e non invia niente in dry-run.
from __future__ import annotations from __future__ import annotations
import sys import sys
from datetime import date
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -21,8 +22,33 @@ telegram_daily = pytest.importorskip("telegram_daily")
def test_build_non_solleva_e_ha_le_sezioni(): def test_build_non_solleva_e_ha_le_sezioni():
txt = telegram_daily.build() txt = telegram_daily.build()
assert isinstance(txt, str) and len(txt) > 50 assert isinstance(txt, str) and len(txt) > 50
for sezione in ("Conto", "Perche' non opera", "Ordini", "Monitor in osservazione"): for sezione in ("Conto", "Ordini", "Monitor in osservazione"):
assert sezione in txt, f"sezione mancante: {sezione}" assert sezione in txt, f"sezione mancante: {sezione}"
# (#35 C14) l'header sul trend e' condizionale: esattamente uno dei due, non piu' fisso.
assert ("Perche' non opera" in txt) != ("Stato trend" in txt)
def test_header_perche_non_opera_solo_se_il_libro_e_flat(monkeypatch):
"""(#35 C14) L'header fisso mentiva quando il libro NON e' fermo (posizioni o target != 0):
con flat=True resta "Perche' non opera"."""
monkeypatch.setattr(telegram_daily, "book_state", lambda: ([" equity $1.00"], True))
assert "Perche' non opera" in telegram_daily.build()
def test_header_neutro_se_il_libro_non_e_flat(monkeypatch):
monkeypatch.setattr(telegram_daily, "book_state",
lambda: ([" BTC: pos $300 → target $300"], False))
txt = telegram_daily.build()
assert "Stato trend" in txt and "Perche' non opera" not in txt
def test_header_neutro_se_il_libro_non_e_leggibile(monkeypatch):
"""Se book_state non sa dire se e' flat (eccezione -> None), niente header "non opera"
dichiarato senza saperlo davvero: si usa il neutro."""
monkeypatch.setattr(telegram_daily, "book_state",
lambda: ([" ⚠️ stato libro non leggibile: Errore"], None))
txt = telegram_daily.build()
assert "Stato trend" in txt and "Perche' non opera" not in txt
def test_nessuna_scrittura_ne_ordine(): def test_nessuna_scrittura_ne_ordine():
@@ -52,3 +78,77 @@ def test_convenzione_trend_e_documentata():
"""La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su: """La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su:
se sparisce dal codice, il report mentirebbe sul 'quanto manca'.""" se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__ assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
def test_soglia_accensione_2_negativi_1_positivo():
"""2 negativi + 1 positivo: basta 1 flip -> soglia = il riferimento negativo PIU' BASSO."""
r = telegram_daily._soglia_accensione([-1, -1, 1], [100.0, 200.0])
assert r == 100.0
def test_soglia_accensione_3_negativi():
"""3 negativi: servono 2 flip -> soglia = il SECONDO riferimento negativo piu' basso, non il
minimo (il bug di #35 C3: superare solo il minimo lascia la media <= 0)."""
r = telegram_daily._soglia_accensione([-1, -1, -1], [100.0, 200.0, 300.0])
assert r == 200.0
def test_soglia_accensione_1_negativo_2_positivi_gia_acceso():
"""1 negativo + 2 positivi: la media e' gia' positiva (TP01 > 0) -> nessuna soglia da dare."""
assert telegram_daily._soglia_accensione([-1, 1, 1], [100.0]) is None
def test_soglia_accensione_nessun_negativo():
"""Tutti positivi: nessun riferimento negativo, nessuna soglia."""
assert telegram_daily._soglia_accensione([1, 1, 1], []) is None
def test_soglia_accensione_servono_tutti_i_flip():
"""2 negativi su 2 (nessun positivo): servono ENTRAMBI i flip -> k == len(neg_refs), soglia
= il piu' alto dei due (l'ultimo a bastare), non il minimo."""
r = telegram_daily._soglia_accensione([-1, -1], [200.0, 100.0])
assert r == 200.0
# ---------------------------------------------------------------- (#35 C14) gate: data, formato
def test_gate_date_legge_da_monitor_health():
"""La data non e' una seconda dichiarazione: viene da monitor_health.MONITORS (`gate`),
la stessa tabella che gli alert dei sorveglianti citano come "METTE A RISCHIO il gate"."""
from src.live.monitor_health import MONITORS
m = next(x for x in MONITORS if x.name == "paper_statarb")
assert m.gate[:10] == "2026-09-27"
assert telegram_daily._gate_date("paper_statarb", date(2030, 1, 1)) == date(2026, 9, 27)
m2 = next(x for x in MONITORS if x.name == "paper_xsr")
assert telegram_daily._gate_date("paper_xsr", date(2030, 1, 1)) == date.fromisoformat(m2.gate[:10])
def test_gate_date_usa_la_cablata_se_il_monitor_non_esiste():
"""Rete di sicurezza: se il nome non e' in MONITORS (o cambia), non si inventa una data."""
assert telegram_daily._gate_date("non_esiste_piu'", date(2030, 1, 1)) == date(2030, 1, 1)
def test_fmt_giorni_tre_casi_mai_il_doppio_segno():
"""(#35 C14) giorni>0 -> '-Ng'; 0 -> 'oggi'; <0 -> 'scaduto da Ng'. Mai '(−-1g)'."""
assert telegram_daily._fmt_giorni(5) == "-5g"
assert telegram_daily._fmt_giorni(0) == "oggi"
assert telegram_daily._fmt_giorni(-1) == "scaduto da 1g"
assert "-1g" not in telegram_daily._fmt_giorni(-1).replace("scaduto da 1g", "") # niente doppio segno
def test_monitors_formatta_i_tre_casi_del_giorno(monkeypatch, tmp_path):
"""I rami di monitors() (barra assente qui: file inesistenti in tmp_path) usano tutti
_fmt_giorni, non il vecchio '(−{giorni}g)' cablato: futuro, oggi e passato si distinguono."""
from datetime import timedelta
oggi = date.today()
monkeypatch.setattr(telegram_daily, "ROOT", tmp_path)
monkeypatch.setattr(telegram_daily, "GATES",
[("FUTURO", oggi + timedelta(days=10), "data/nonexiste1/r.jsonl", "k"),
("OGGI", oggi, "data/nonexiste2/r.jsonl", "k"),
("PASSATO", oggi - timedelta(days=3), "data/nonexiste3/r.jsonl", "k")])
righe = telegram_daily.monitors()
assert "(-10g)" in righe[0]
assert "(oggi)" in righe[1]
assert "(scaduto da 3g)" in righe[2]
assert "−-" not in "".join(righe) and "(−" not in "".join(righe) # mai il doppio segno
+120
View File
@@ -0,0 +1,120 @@
"""scripts/live/trades_db.py:sync() — controllo di conteggio jsonl-vs-DB (#37 C11, #35 A9).
Dal 2026-08-24T01:07Z (`tradesdb.CUTOFF_TS_VERO`) `book_executions.jsonl` porta l'ora VERA del
fill: un conteggio diverso da quello del DB in quella finestra segnala che una delle due fonti
sta perdendo fill. `sync()` non ripara (come `reconcile()`): riporta e notifica UNA volta per
episodio, con lo stesso marcatore-solo-su-invio-riuscito di fee_watch (#37 B9).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live import tradesdb as T # noqa: E402
import trades_db as TD # noqa: E402
@pytest.fixture()
def redirige(monkeypatch, tmp_path):
"""DB, cron_book.log e jsonl finti in tmp_path: non si tocca mai il libro vero."""
reale = T.connect
monkeypatch.setattr(T, "connect", lambda *a, **k: reale(tmp_path / "t.db"))
monkeypatch.setattr(T, "CRON_LOG", tmp_path / "cron_book.log") # assente -> nessun fill dal log
monkeypatch.setattr(T, "EXEC_JSONL", tmp_path / "book_executions.jsonl")
return tmp_path
def _semina_db(tmp_path, n, ts=None):
ts = ts or T.CUTOFF_TS_VERO
con = T.connect(tmp_path / "t.db")
fills = [T.Fill(ts_utc=ts, asset="BTC", side="buy", qty=0.001 * (i + 1), price=70000.0, fee=0.01)
for i in range(n)]
T.upsert_fills(con, fills)
con.close()
def _scrivi_jsonl(tmp_path, n, ts=None):
ts = ts or T.CUTOFF_TS_VERO
righe = [dict(ts_utc=ts, asset="BTC", side="buy", filled=0.001 * (i + 1), price=70000.0, fee=0.01)
for i in range(n)]
(tmp_path / "book_executions.jsonl").write_text(
"\n".join(json.dumps(r) for r in righe) + ("\n" if righe else ""))
def test_conteggi_uguali_non_notifica(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 2)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert inviato == []
assert r["jsonl_dal_24_08"] == r["db_dal_24_08"] == 2
def test_conteggi_diversi_notifica_una_volta_per_episodio(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert len(inviato) == 1, "conteggi diversi: un invio"
assert r["jsonl_dal_24_08"] == 3 and r["db_dal_24_08"] == 2
# secondo giro, stessa divergenza -> nessun invio aggiuntivo (marcatore gia' scritto)
r2 = TD.sync(verbose=False)
assert len(inviato) == 1, "stesso episodio: nessun secondo invio"
assert r2["jsonl_dal_24_08"] == 3 and r2["db_dal_24_08"] == 2
def test_invio_fallito_non_marca_ritenta_al_giro_dopo(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or False)
TD.sync(verbose=False)
TD.sync(verbose=False)
assert len(inviato) == 2, "invio fallito -> marcatore non scritto, si ritenta ogni giro"
def test_conteggio_rientrato_azzera_il_marcatore(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
monkeypatch.setattr(TD, "notify", lambda *a, **k: True)
TD.sync(verbose=False) # divergenza detta
# il jsonl si allinea (es. arrivato il fill mancante) -> episodio chiuso
_scrivi_jsonl(tmp_path, 2)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
TD.sync(verbose=False)
assert inviato == [], "conteggi tornati uguali: nessun invio da fare"
# una NUOVA divergenza deve poter notificare di nuovo (il marcatore era stato azzerato)
_scrivi_jsonl(tmp_path, 4)
TD.sync(verbose=False)
assert len(inviato) == 1
def test_fill_prima_del_24_08_non_entra_nel_conteggio(monkeypatch, redirige):
"""Prima di CUTOFF_TS_VERO il ts_utc del jsonl e' la barra di segnale, non l'ora vera: non
e' comparabile col DB, e non deve contare come divergenza."""
tmp_path = redirige
_semina_db(tmp_path, 0)
_scrivi_jsonl(tmp_path, 2, ts="2026-08-10T00:00:00+00:00") # prima della soglia
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert inviato == []
assert r["jsonl_dal_24_08"] == 0 and r["db_dal_24_08"] == 0
+10 -2
View File
@@ -116,7 +116,11 @@ def test_fifo_non_mescola_gli_asset():
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)] _f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
rts, aperti = T.fifo_roundtrips(fills) rts, aperti = T.fifo_roundtrips(fills)
assert rts == [] # la vendita ETH non chiude il long BTC assert rts == [] # la vendita ETH non chiude il long BTC
assert set(aperti) == {"BTC"} # (#32 C2) una vendita ETH senza lotti long apre un SHORT — non sparisce piu' in silenzio:
# e' comunque isolata dal long BTC, che e' quel che il test verifica.
assert set(aperti) == {"BTC", "ETH"}
assert aperti["BTC"][0]["direzione"] == "long"
assert aperti["ETH"][0]["direzione"] == "short"
def test_fifo_riproduce_il_conto_del_libro_vero(): def test_fifo_riproduce_il_conto_del_libro_vero():
@@ -200,8 +204,12 @@ def test_l_esecutore_non_data_piu_i_fill_alla_barra_di_segnale():
"""Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`. """Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`.
Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte. Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte.
Dal refactor U2 (#23) la registrazione di un fill vive in `_registra_fill(...)`, che riceve
`bar_ts` come parametro invece di leggere `r['last_data']` alla riga del log_event: il call
site passa `bar_ts=r["last_data"]`.
""" """
src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text() src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text()
assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src
assert "bar_ts=str(pd.Timestamp(r['last_data']))" in src assert "bar_ts=str(pd.Timestamp(bar_ts))" in src # dentro _registra_fill
assert 'bar_ts=r["last_data"]' in src # il call site passa la barra
assert "ts_utc=datetime.now(timezone.utc)" in src assert "ts_utc=datetime.now(timezone.utc)" in src
+147
View File
@@ -0,0 +1,147 @@
"""tradesdb.arricchisci_da_jsonl (#40, TradingBook#11): le righe `ts_source='cron_log'` dal
24/08 (`CUTOFF_TS_VERO`) prendono l'ora vera, il prezzo pieno e `pos_after` dal jsonl, SOLO
quando l'abbinamento (asset, side, qty, finestra di 15 minuti) e' univoco. `fill_id` e' la
chiave della riga: l'arricchimento aggiorna le colonne con un UPDATE per `fill_id`, mai la
chiave stessa.
Nessun test tocca la rete o il libro vero: DB e righe jsonl sono finti, in tmp_path.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402
# ts di riferimento: subito dopo la soglia, cosi' i fill sono sempre candidati.
T0 = "2026-08-24T02:00:00+00:00"
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def _f(ts, asset="BTC", side="buy", qty=0.005, price=70000.0, fee=0.02):
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee)
def _riga_jsonl(ts, asset="BTC", side="buy", filled=0.005, price=70123.4, pos_after=None):
return dict(ts_utc=ts, asset=asset, side=side, filled=filled, price=price, pos_after=pos_after)
def test_abbinamento_unico_aggiorna_e_non_cambia_fill_id(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
fill_id_prima = fill.fill_id
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)] # +5 min, in finestra
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (1, 0)
riga = db.execute("SELECT * FROM fills WHERE fill_id=?", (fill_id_prima,)).fetchone()
assert riga is not None, "il fill_id originale resta la chiave: l'UPDATE non lo cambia"
assert riga["ts_source"] == "jsonl"
assert riga["ts_utc"] == "2026-08-24T02:05:00+00:00"
assert riga["price"] == 70123.4
assert riga["pos_after"] == 555.0
# nessuna riga in piu' o in meno: l'arricchimento aggiorna, non inserisce.
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 1
def test_pos_after_none_nel_jsonl_non_sovrascrive_quello_gia_nel_db(db):
"""(#40, punto 1) `pos_after=None` nella riga jsonl non e' un valore: la colonna resta
quella che aveva (qui: None dal cron_log, ma la regola e' 'non toccare', non 'azzerare')."""
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=None)]
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (1, 0)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "jsonl" and riga["price"] == 70123.4
assert riga["pos_after"] is None
def test_due_jsonl_compatibili_e_ambiguo_non_si_tocca(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:03:00+00:00", price=70100.0),
_riga_jsonl("2026-08-24T02:07:00+00:00", price=70200.0)] # entrambe in finestra
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (0, 1)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log" # non toccata
assert riga["ts_utc"] == T0
assert riga["price"] == 70000.0
def test_secondo_giro_zero_aggiornati_idempotente(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)]
assert T.arricchisci_da_jsonl(db, righe) == (1, 0)
# la riga e' gia' ts_source='jsonl': non e' piu' candidata, anche con le stesse righe jsonl.
assert T.arricchisci_da_jsonl(db, righe) == (0, 0)
assert db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"] == 1
def test_riga_prima_del_cutoff_non_e_candidata(db):
ts_prima = "2026-08-10T00:00:00+00:00" # prima di CUTOFF_TS_VERO (24/08)
fill = _f(ts_prima)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-10T00:05:00+00:00", pos_after=1.0)] # abbinamento perfetto
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (0, 0) # non e' ambigua: non e' nemmeno candidata
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log" and riga["ts_utc"] == ts_prima and riga["price"] == 70000.0
def test_fuori_dalla_finestra_dei_15_minuti_non_abbina(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:16:00+00:00", pos_after=1.0)] # +16 min: fuori finestra
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log"
def test_qty_diversa_non_abbina(db):
fill = _f(T0, qty=0.005)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", filled=0.006, pos_after=1.0)]
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
def test_record_jsonl_usato_una_volta_non_si_riusa(db):
"""Due fill IDENTICI nello stesso giro (stesso asset/side/qty, ts diversi) non devono
finire abbinati alla stessa riga jsonl: la prima riga jsonl la prende un solo fill_id."""
f1 = _f("2026-08-24T02:00:00+00:00")
f2 = _f("2026-08-24T02:01:00+00:00") # ts diverso -> fill_id diverso, stesso asset/side/qty
T.upsert_fills(db, [f1, f2])
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=1.0)] # UNA sola riga jsonl
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
# un solo candidato jsonl per due righe DB candidate: la prima (ordinata per ts_utc) lo
# prende, la seconda resta senza candidati liberi -> ambigua (zero disponibili).
assert aggiornate == 1
assert ambigue == 1
presi = db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"]
assert presi == 1
+120
View File
@@ -0,0 +1,120 @@
"""Test nuovi per l'unita' U4 (issue #32 C2, C10) — tradesdb.py.
Non ripete la suite esistente (`test_tradesdb.py`): quella resta bit-exact sui casi
long-aperto/long-chiuso (vedi il commit). Qui solo cio' che il fix aggiunge: lotti SHORT,
inversione in un fill, e i fill a qty<=0 fuori dal FIFO.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402
def _f(ts, asset, side, qty, price, fee=0.0, verified=1):
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee,
verified=verified)
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
# ---------------------------------------------------------------- short: apertura e chiusura
def test_una_vendita_senza_lotti_long_apre_un_short(db):
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0)]
rts, aperti = T.fifo_roundtrips(fills)
assert rts == [] # niente da chiudere: solo apertura
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0)
assert aperti["BTC"][0]["price"] == pytest.approx(100.0)
assert aperti["BTC"][0]["direzione"] == "short"
def test_un_acquisto_chiude_il_short_pnl_entry_meno_exit(db):
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0), # apre short @100
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 90.0, fee=1.0)] # chiude @90
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1
r = rts[0]
assert r["direzione"] == "short"
assert r["px_in"] == 100.0 and r["px_out"] == 90.0
assert r["pnl_lordo"] == pytest.approx(10.0) # venduto alto, comprato basso: profitto
assert r["fee_quota"] == pytest.approx(2.0)
assert r["pnl_netto"] == pytest.approx(8.0)
assert aperti == {} # flat: nessun lotto residuo
def test_chiusura_parziale_di_uno_short_lascia_il_resto_short(db):
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0),
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 0.25, 80.0)]
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1 and rts[0]["qty"] == pytest.approx(0.25)
assert rts[0]["pnl_lordo"] == pytest.approx(0.25 * (100.0 - 80.0))
assert aperti["ETH"][0]["qty"] == pytest.approx(0.75)
assert aperti["ETH"][0]["direzione"] == "short"
# ---------------------------------------------------------------- inversione in un solo fill
def test_inversione_long_a_short_in_un_fill_non_scarta_il_resto(db):
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), # long 1.0 @100
_f("2026-01-02T00:00:00+00:00", "BTC", "sell", 2.0, 150.0)] # chiude + apre short
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1
assert rts[0]["direzione"] == "long" and rts[0]["qty"] == pytest.approx(1.0)
assert rts[0]["pnl_lordo"] == pytest.approx(50.0) # (150-100)*1
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) # il resto: 2.0-1.0
assert aperti["BTC"][0]["price"] == pytest.approx(150.0)
assert aperti["BTC"][0]["direzione"] == "short" # nessun resto scartato
def test_inversione_short_a_long_in_un_fill(db):
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), # short 1.0 @100
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 3.0, 90.0)] # chiude + apre long
rts, aperti = T.fifo_roundtrips(fills)
assert len(rts) == 1
assert rts[0]["direzione"] == "short" and rts[0]["qty"] == pytest.approx(1.0)
assert rts[0]["pnl_lordo"] == pytest.approx(10.0) # (100-90)*1
assert aperti["ETH"][0]["qty"] == pytest.approx(2.0) # il resto: 3.0-1.0
assert aperti["ETH"][0]["direzione"] == "long"
def test_stato_aperto_riporta_la_direzione_short(db):
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)])
st = T.stato_aperto(db)
assert st["ETH"]["qty"] == pytest.approx(1.0)
assert st["ETH"]["direzione"] == "short"
# ---------------------------------------------------------------- #32 C10: fill a qty<=0
def test_fill_a_qty_zero_non_entra_nel_fifo(db):
T.upsert_fills(db, [
_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 0.0, 100.0, verified=0), # place_order error
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
_f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)])
assert T.rebuild_roundtrips(db) == 1 # solo il round-trip vero, non uno spurio
rt = db.execute("SELECT * FROM roundtrips").fetchall()
assert len(rt) == 1 and rt[0]["qty"] == pytest.approx(1.0)
def test_fill_a_qty_zero_non_entra_in_stato_aperto_ma_resta_nel_db(db):
T.upsert_fills(db, [
_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 0.0, 100.0, verified=0), # place_order error
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 100.0)])
st = T.stato_aperto(db)
assert st["ETH"]["qty"] == pytest.approx(1.0) # il fill a qty 0 non conta come lotto
# ma resta leggibile nel DB, marcato NON verificato — non e' sparito, e' escluso dal FIFO
righe = db.execute("SELECT qty, verified FROM fills WHERE qty = 0").fetchall()
assert len(righe) == 1 and righe[0]["verified"] == 0
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
+259
View File
@@ -0,0 +1,259 @@
"""usde_convert.py — GTC mai cancellati (issue #26).
Il gateway cerbero-mcp NON accetta `time_in_force` (PlaceOrderReq non ha il campo, extra
ignorati: gitea:Adriano/Cerbero-mcp#1): un limit riempito solo in parte resta APERTO per
sempre, e senza guardia si accumula a ogni giro dello script. Blindano: (a) all'avvio si
cancellano SOLO i residui con la nostra label (`usde-quota-*`), un ordine con altra label non
si tocca; (b) nel ciclo, un ordine con `filled < amt` viene cancellato subito dopo; (c) se la
cancellazione fallisce il ciclo si FERMA (mai un secondo ordine con un residuo vivo).
Client finto (nessuna rete), `time.sleep` patchato: nessun ordine vero puo' partire da questi
test.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
import usde_convert as C # noqa: E402
from src.live import usde as U # noqa: E402
# =============================================================================================
# cancella_residui — SOLA lettura + cancellazione dei nostri ordini
# =============================================================================================
class _ClientOrdini:
"""Fake minimale per _post/_unwrap: nessuna rete."""
def __init__(self, open_orders=None, open_orders_usde=None, risposte_place=None,
boom_cancel=None):
self.open_orders = open_orders or []
# None = stessa lista di open_orders (retrocompat dei test esistenti, che non
# distinguono la valuta interrogata); una lista propria serve solo a chi vuole
# simulare un residuo elencato SOLO sotto USDE.
self.open_orders_usde = open_orders_usde
self.risposte_place = list(risposte_place or [])
self.boom_cancel = boom_cancel or set() # order_id la cui cancel fallisce
self.cancellati = []
self.get_open_orders_chiamate = 0
def _post(self, path, payload):
if path.endswith("get_open_orders"):
self.get_open_orders_chiamate += 1
if payload.get("currency") == "USDE" and self.open_orders_usde is not None:
return {"result": self.open_orders_usde}
return {"result": self.open_orders}
if path.endswith("cancel_order"):
oid = payload["order_id"]
self.cancellati.append(oid)
if oid in self.boom_cancel:
# forma reale di client.py::cancel_order su un fallimento del venue: il gateway
# avvolge il dict del tool in {"result": ...}, MAI in {"result": None, "error"}
return {"result": {"order_id": oid, "state": "error", "error": "cancel rifiutata dal venue"}}
return {"result": {"order_id": oid, "state": "cancelled"}}
if path.endswith("place_order"):
if not self.risposte_place:
raise AssertionError("place_order chiamato senza risposte in coda")
return self.risposte_place.pop(0)
raise AssertionError(f"chiamata inattesa: {path}")
@staticmethod
def _unwrap(resp):
return resp.get("result", resp) if isinstance(resp, dict) else resp
def test_cancella_residui_solo_le_nostre_label():
client = _ClientOrdini(open_orders=[
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, # NON nostro
{"instrument": "BTC_USDC-PERPETUAL", "label": "usde-quota-0.64", "order_id": "R3"},
])
out = C.cancella_residui(client)
assert out == ["R1"]
assert client.cancellati == ["R1"], "l'ordine con altra label o su altro strumento non si tocca"
def test_cancella_residui_trovato_solo_sotto_USDE():
"""Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: se lo elenca
sotto la base (USDE) invece che sotto la quotazione (USDC), la sola lettura USDC non lo
vedrebbe mai e il residuo si accumulerebbe per sempre."""
client = _ClientOrdini(
open_orders=[],
open_orders_usde=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R9"}],
)
out = C.cancella_residui(client)
assert out == ["R9"]
assert client.cancellati == ["R9"]
def test_cancella_residui_senza_residui_non_cancella_nulla():
client = _ClientOrdini(open_orders=[
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"},
])
assert C.cancella_residui(client) == []
assert client.cancellati == []
def test_cancella_residui_lettura_fallita_solleva():
"""P5: non si ordina al buio. Se la LETTURA degli ordini aperti fallisce, la funzione
deve sollevare invece di procedere come se non ci fosse nulla da cancellare."""
class _ClientRotto:
def _post(self, path, payload):
raise RuntimeError("gateway giu'")
@staticmethod
def _unwrap(resp):
return resp
with pytest.raises(Exception):
C.cancella_residui(_ClientRotto())
def test_cancella_residui_un_residuo_che_non_si_cancella_solleva():
"""Se non riusciamo a liberare un residuo NOSTRO, non si procede: si solleva (non si piazza
un nuovo ordine sopra un residuo che non siamo riusciti a togliere)."""
client = _ClientOrdini(open_orders=[
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
], boom_cancel={"R1"})
with pytest.raises(Exception):
C.cancella_residui(client)
# =============================================================================================
# _cancella — l'helper di basso livello usato sia da cancella_residui sia dal ciclo di main()
# =============================================================================================
def test__cancella_ok():
client = _ClientOrdini()
ok, motivo = C._cancella(client, "X1")
assert ok and motivo == "" and client.cancellati == ["X1"]
def test__cancella_errore_nel_payload_non_solleva():
client = _ClientOrdini(boom_cancel={"X1"})
ok, motivo = C._cancella(client, "X1")
assert not ok and "rifiutata" in motivo
def test__cancella_eccezione_di_rete_si_registra_non_si_propaga():
class _Rotto:
def _post(self, path, payload):
raise RuntimeError("timeout")
@staticmethod
def _unwrap(resp):
return resp
ok, motivo = C._cancella(_Rotto(), "X1")
assert not ok and "timeout" in motivo
# =============================================================================================
# main() con --esegui: cancellazione dei residui PRIMA del ciclo, e dopo ogni ordine parziale
# =============================================================================================
def _piano_finto(**over):
base = dict(ok=True, side="buy", amount=10.0, px=1.0, px_ordine=1.0000, motivi=[],
quota_ora=0.20, quota_target=0.64, cap_usd=None, cap_frac=0.0,
val_usde=500.0, equity_tot=1500.0, delta_usd=10.0,
usdc_dopo=990.0, cuscino=300.0, cuscino_come="test")
base.update(over)
return base
def _prepara_main(monkeypatch, client, tmp_path, argv_extra=()):
monkeypatch.setattr(C, "DeribitRead", lambda: client)
monkeypatch.setattr(C, "leggi_stato", lambda c: (1000.0, 500.0, 1.0, "test"))
monkeypatch.setattr(C, "book_top", lambda *a, **k: (0.9999, 1.0001))
monkeypatch.setattr(C, "piano", lambda *a, **k: _piano_finto())
monkeypatch.setattr(C.time, "sleep", lambda s: None)
monkeypatch.setattr(C, "LOG", tmp_path / "usde_convert.jsonl")
monkeypatch.setattr(C, "ROOT", tmp_path) # solo per il print finale (LOG.relative_to(ROOT))
monkeypatch.setattr(sys, "argv", ["usde_convert.py", "--quota", "0.64", "--esegui", *argv_extra])
def test_ordine_con_filled_zero_viene_cancellato_e_il_ciclo_si_ferma(monkeypatch, tmp_path, capsys):
client = _ClientOrdini(
open_orders=[],
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "open",
"filled_amount": 0}, "trades": []}}],
)
_prepara_main(monkeypatch, client, tmp_path)
rc = C.main()
out = capsys.readouterr().out
assert client.cancellati == ["O1"], "un ordine non fillato deve essere cancellato, non lasciato aperto"
assert "residuo cancellato" in out
assert rc == 1 # nessun fill -> esito negativo, comportamento invariato
def test_cancel_che_fallisce_ferma_il_ciclo_e_non_manda_un_secondo_ordine(monkeypatch, tmp_path, capsys):
client = _ClientOrdini(
open_orders=[],
risposte_place=[
{"result": {"order": {"order_id": "BOOM", "order_state": "open",
"filled_amount": 0}, "trades": []}},
{"result": {"order": {"order_id": "O2", "order_state": "open",
"filled_amount": 10}, "trades": []}}, # non deve mai partire
],
boom_cancel={"BOOM"},
)
_prepara_main(monkeypatch, client, tmp_path)
rc = C.main()
out = capsys.readouterr().out
assert client.cancellati == ["BOOM"]
assert "FALLITA" in out and "mai un secondo ordine" in out
assert len(client.risposte_place) == 1, "il secondo ordine non deve essere stato piazzato"
assert rc == 1
def test_ordine_fillato_per_intero_non_si_cancella(monkeypatch, tmp_path, capsys):
"""filled == amt: nessun residuo, nessuna cancellazione (P3: non si spreca una chiamata su
un ordine che non ha lasciato nulla aperto)."""
client = _ClientOrdini(
open_orders=[],
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
"filled_amount": 10.0}, "trades": []}}],
)
_prepara_main(monkeypatch, client, tmp_path)
# piano() e' chiamato una volta PRIMA del ciclo (piano dry-run) e una volta per ogni giro:
# le prime due chiamate (piano pre-ciclo + 1° giro) sono valide, la terza (2° giro) dice
# 'gia' a bersaglio' per fermare il ciclo dopo il primo (e unico) ordine.
chiamate = {"n": 0}
def piano_poi_fine(*a, **k):
chiamate["n"] += 1
if chiamate["n"] <= 2:
return _piano_finto()
return _piano_finto(ok=False, motivi=["gia' a bersaglio"])
monkeypatch.setattr(C, "piano", piano_poi_fine)
C.main()
out = capsys.readouterr().out
assert client.cancellati == []
assert "residuo cancellato" not in out
def test_residui_all_avvio_vengono_cancellati_prima_del_ciclo(monkeypatch, tmp_path, capsys):
client = _ClientOrdini(
open_orders=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "VECCHIO"}],
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
"filled_amount": 10.0}, "trades": []}}],
)
_prepara_main(monkeypatch, client, tmp_path)
# piano() e' chiamato una volta PRIMA del ciclo e una volta per giro: le prime due chiamate
# sono valide (piano pre-ciclo + 1° giro, che piazza l'unico ordine), la terza ferma il ciclo.
chiamate = {"n": 0}
def piano_poi_fine(*a, **k):
chiamate["n"] += 1
return _piano_finto() if chiamate["n"] <= 2 else _piano_finto(ok=False, motivi=["fine"])
monkeypatch.setattr(C, "piano", piano_poi_fine)
C.main()
out = capsys.readouterr().out
assert "VECCHIO" in client.cancellati
assert "residui cancellati all'avvio: 1" in out
# e il residuo pre-esistente e' stato cancellato PRIMA che partisse il primo place_order
assert client.cancellati.index("VECCHIO") == 0
+44 -12
View File
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
assert r["apr"] is None and r["finestre"] == 0 assert r["apr"] is None and r["finestre"] == 0
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO(): def test_quota_da_decidere_e_stata_rimossa():
from datetime import datetime, timezone """#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC
(usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni
def q(giorno, v): giorno finche' non la si toglie a mano."""
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v) assert not hasattr(W, "quota_da_decidere")
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
assert q("2026-08-31", "NON_IDONEO") is False
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------ # ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
@@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano():
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}] rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now) tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9 assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
# =============================================================================================
# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch)
# =============================================================================================
def test_notify_fallito_la_condizione_si_ripresenta_come_nuova():
"""Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva ->
un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva
l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'."""
att = {"QUOTA_OVER"}
prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO
assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"}
def test_notify_riuscito_non_si_ripete():
att = {"QUOTA_OVER"}
prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta
assert W.nuove_condizioni(att, att, prev) == set()
def test_record_vecchio_senza_allertati_vale_come_tutto_allertato():
"""Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non
deve produrre un rispam retroattivo delle condizioni gia' viste."""
att = {"QUOTA_OVER", "BLIND"}
prev = dict(stato="OK") # nessun campo `allertati`
assert W.nuove_condizioni(att, att, prev) == set()
def test_senza_record_precedente_tutto_e_nuovo():
att = {"QUOTA_OVER", "DEPEG_WARN"}
assert W.nuove_condizioni(att, set(), None) == att
def test_una_condizione_appena_comparsa_e_sempre_nuova():
"""Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per
definizione — l'insieme confermato non la riguarda."""
assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}
+12 -32
View File
@@ -8,17 +8,17 @@ Cosa difendono, in ordine di gravita':
da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta. da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta.
2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto. 2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto.
3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5). 3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5).
4. **P9**: `atteso` declassa la gravita' SOLO su evidenza di manutenzione, e SOLO dentro lo slot. 4. **`atteso` e' sempre False** (#35 B11): il ramo che lo declassava (finestra di release del
martedi') e' stato rimosso perche' l'unico chiamante (cron :47) non ci cadeva mai dentro.
""" """
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose, from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose,
errori_dal_report, in_release_window) errori_dal_report)
MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":' MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":'
'{"message":"system_maintenance","code":11051}}') '{"message":"system_maintenance","code":11051}}')
@@ -94,39 +94,19 @@ def test_la_prova_viaggia_sempre_con_il_verdetto():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. P9 — `atteso` declassa, ma solo su EVIDENZA e solo DENTRO lo slot. # 2. `atteso` e' sempre False (#35 B11) — nessun verdetto, manutenzione compresa, si declassa.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_finestra_di_release_martedi_nove_utc(): def test_manutenzione_non_si_declassa_piu_atteso_sempre_falso():
dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero """Il ramo che declassava (finestra di release del martedi') e' rimosso: una manutenzione
assert in_release_window(dentro) is True riconosciuta dalla sonda resta comunque `atteso is False` e alla gravita' piena."""
assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True d = diagnose([], sonda=_sonda_manutenzione)
assert in_release_window(datetime(2026, 8, 25, 9, 29, tzinfo=timezone.utc)) is True
assert in_release_window(datetime(2026, 8, 25, 9, 30, tzinfo=timezone.utc)) is False # sforata
assert in_release_window(datetime(2026, 8, 25, 8, 59, tzinfo=timezone.utc)) is False
assert in_release_window(datetime(2026, 8, 26, 9, 7, tzinfo=timezone.utc)) is False # mercoledi'
def test_manutenzione_dentro_lo_slot_e_attesa_e_declassa():
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is True
assert d.gravita == "ℹ️" # declassato, NON silenziato
assert "ATTESO" in d.riga()
def test_manutenzione_che_sfora_lo_slot_torna_rumorosa():
"""Il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora: alle 10:07 la
manutenzione non era piu' l'evento annunciato, ed e' di nuovo una notizia."""
d = diagnose([], now=datetime(2026, 8, 18, 10, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is False assert d.verdetto == V_MANUTENZIONE and d.atteso is False
assert d.gravita != "ℹ️" assert d.gravita == "⚠️"
assert "ATTESO" not in d.riga()
def test_un_guasto_vero_di_martedi_mattina_non_viene_declassato(): def test_un_guasto_vero_non_e_mai_atteso():
"""Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
rotto dentro lo slot di release deve restare al massimo della gravita'."""
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), sonda=_sonda_ok)
assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑" assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"
+81
View File
@@ -499,3 +499,84 @@ def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito.""" """'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None) sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
assert "non tentato" in rep["invio"] assert "non tentato" in rep["invio"]
# =============================================================================================
# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default
# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non
# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo).
# =============================================================================================
def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path):
sp = tmp_path / "vw.json"
def fake():
return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False,
"assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None)
for a in VW.ASSETS},
"deribit_errors": []}
monkeypatch.setattr(VW, "observe", fake)
rep = None
for _ in range(VW.BLIND_ALERT_HOURS):
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
assert any(l == "BLIND" for l in rep["levels"].values())
assert not any(l == "ALERT" for l in rep["levels"].values())
assert rep["severity"] == "blind"
def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path):
"""Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9).
BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`);
ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS`
round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND."""
sp = tmp_path / "vw.json"
letture = {"n": 0}
soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak
def fake():
letture["n"] += 1
eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0
return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false",
"maintenance": False,
"assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None),
"ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)},
"deribit_errors": []}
monkeypatch.setattr(VW, "observe", fake)
rep = None
for _ in range(VW.BLIND_ALERT_HOURS):
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
assert rep["levels"]["BTC"] == "BLIND"
assert rep["levels"]["ETH"] == "ALERT"
assert rep["severity"] == "alert"
def _carica_script_venue_watch():
"""scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome
del modulo `src.live.venue_watch` gia' importato sopra come `VW`."""
import importlib.util
sp = importlib.util.spec_from_file_location(
"venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
def test_titolo_blind_non_dice_prelievo():
SVW = _carica_script_venue_watch()
t = SVW._titolo({"severity": "blind"})
assert "prelievo" not in t.lower()
assert "CIECO" in t
def test_titolo_alert_dice_ancora_prelievo():
SVW = _carica_script_venue_watch()
assert "prelievo" in SVW._titolo({"severity": "alert"}).lower()
assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta
def test_titolo_warn_resta_quello_di_prima():
SVW = _carica_script_venue_watch()
assert "Nessuna azione" in SVW._titolo({"severity": "warn"})