19 Commits

Author SHA1 Message Date
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
26 changed files with 2105 additions and 209 deletions
+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,
telegram: bool = True) -> str:
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)
st = storico(con, g)
prompt = A.costruisci_prompt(pagina, st)
+103 -28
View File
@@ -46,7 +46,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
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.venue_probe import diagnose, errori_dal_report
@@ -85,6 +85,31 @@ def log_event(rec: dict):
f.write(json.dumps(rec) + "\n")
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int):
"""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` e' sempre None qui: la posizione dopo il rebalance si legge (best-effort) DOPO
aver registrato tutti i fill, in `_run` — vedi il commento li'."""
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=None,
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():
cfg = load_config()
want_execute = "--execute" in sys.argv[1:]
@@ -129,8 +154,10 @@ def _run():
print(f" ultima barra : {r['last_data']}\n")
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']}")
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
# 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) --------------------------
# `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e
@@ -220,10 +247,27 @@ def _run():
"azione": "rebuild_history.py --asset BTC ETH"})
return
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
# (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
# questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
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
# NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il
@@ -250,13 +294,18 @@ def _run():
# non deve togliere la rete di sicurezza all'altro.
falliti: list[str] = []
scoperti: list[str] = []
non_verificati: list[str] = []
for a in r["assets"]:
asset, inst = a["asset"], a["instrument"]
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
sk = a["skh_state"]
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
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)"
elif order.get("is_close"):
act = f"CLOSE ${cur:,.0f}"
@@ -268,26 +317,32 @@ def _run():
f"| pos ${cur:+,.0f} -> {act}")
try:
if do_execute and order is not None:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
newpos = trader.position_usd(inst)
if do_execute and order is not None and not solo_protezione:
# (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
# 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
for f in fills:
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(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}")
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
# newpos e' un RECONCILE informativo, letto DOPO aver registrato i fill (#23): se
# questa lettura solleva, i fill sono gia' salvi (sopra), e non e' un guasto del
# giro (isolamento) -> si segnala e si continua (disaster-SL sotto, comunque).
try:
newpos = trader.position_usd(inst)
print(f" reconcile: pos ${newpos:,.0f}")
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
print(f" reconcile: pos illeggibile ({type(e).__name__})")
if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
stato = ds.get("state")
@@ -308,6 +363,15 @@ def _run():
"amount": round(ds.get("amount") or 0, 4),
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
"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
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
falliti.append(asset)
@@ -327,16 +391,27 @@ def _run():
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
if 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:
print(" => DRY-RUN: nessun ordine inviato." +
("" 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):
print(" => Nessuna azione: conto gia' al target netto del book.")
else:
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
# 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)
+11 -3
View File
@@ -245,12 +245,20 @@ def main() -> int:
else:
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
if r["changes"] or r["level"] == "AZIONE":
notify("💸 FEE WATCH — schema fee Deribit", {
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
# 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']}",
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
})
save_state(r["current"])
if not inviato and (not quiet or interessante):
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
if not serve_invio or inviato:
save_state(r["current"])
registra_giro(RUNS, level=r["level"])
return 2 if r["level"] == "AZIONE" else 0
+3 -1
View File
@@ -125,7 +125,9 @@ def report() -> None:
if ap:
print(" posizioni aperte :")
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:
print(" posizioni aperte : nessuna (flat)")
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
+68
View File
@@ -93,6 +93,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.
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]:
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
@@ -232,6 +285,9 @@ def main() -> int:
return 0
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
for _ in range(60):
eq_usdc, eq_usde, px, _f = leggi_stato(c)
@@ -274,6 +330,18 @@ def main() -> int:
"amount": amt, "filled": filled})
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
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:
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
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
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
# --- 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")
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]:
"""PURA. Condizioni di allerta attive su un record."""
if not rec:
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
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():
try:
from src.live.deribit import DeribitRead
@@ -284,54 +285,58 @@ def main() -> int:
v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
# issue #29 (B2): 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. Schema di
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
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 = []
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
if "DEPEG_CRIT" in att_now:
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
"collaterale": f"${usd:,.0f}" if usd else "?",
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
"collaterale": f"${usd:,.0f}" if usd else "?",
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
inviati.append("DEPEG_CRIT")
if ok:
allertati.add("DEPEG_CRIT")
if "DEPEG_WARN" in nuove:
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"})
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"})
inviati.append("DEPEG_WARN")
if ok:
allertati.add("DEPEG_WARN")
if "QUOTA_OVER" in nuove:
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
"tetto": f"{c['quota_max_frac']:.0%}",
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
"tetto": f"{c['quota_max_frac']:.0%}",
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
inviati.append("QUOTA_OVER")
if ok:
allertati.add("QUOTA_OVER")
if "BLIND" in nuove:
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
inviati.append("BLIND")
if ok:
allertati.add("BLIND")
rec["allertati"] = sorted(allertati)
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
if v != v_prev:
if v == "IDONEO":
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":
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
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])
if quota_da_decidere(now, v):
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
"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")
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
cambiato = bool(inviati) or v != v_prev
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'}"
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
if quota is not None:
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
# Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
# NON e' piu' il Segregated Standard Margin di prima di quella data.
cross = eq_usdc + usd * (1.0 - c["haircut"])
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"
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
f" {c['haircut']:.0%})")
if an["delta"] is not None:
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
@@ -366,8 +369,6 @@ def main() -> int:
f" (tot {rend['tot']:+.6f} USDE)")
if rend["finestre"] < 4:
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:
print(f" allarmi : {', '.join(inviati)}")
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
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."""
if rep.get("severity") == "blind":
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
if rep.get("severity") == "warn":
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
+26 -9
View File
@@ -236,11 +236,22 @@ class DeribitRead:
return out.get("positions", []) if isinstance(out, dict) else []
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"]
for p in self.positions(cur):
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
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
@@ -251,17 +262,23 @@ class DeribitRead:
def open_orders(self, instrument: str) -> list[dict]:
"""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"]
seen: dict = {}
for typ in ("all", "trigger_all"):
try:
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
{"currency": cur, "type": typ}))
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
for o in lst:
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
seen[o.get("order_id")] = o
except Exception:
pass
except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
for o in lst:
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
seen[o.get("order_id")] = o
return list(seen.values())
+98 -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
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
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).
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
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).
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente.
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
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
@@ -17,11 +22,13 @@ from dataclasses import dataclass
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
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima.
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
# DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il
# micro-test, 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.
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
@@ -29,6 +36,19 @@ class GuardrailError(RuntimeError):
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
class Fill:
"""Esito verificato di un ordine reale."""
@@ -55,6 +75,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
class DeribitTrader(DeribitRead):
"""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,
label: str, order_type: str = "market", price: float | None = None) -> Fill:
if instrument not in ALLOWED:
@@ -62,7 +96,9 @@ class DeribitTrader(DeribitRead):
if side not in ("buy", "sell"):
raise GuardrailError(f"side non valido: {side}")
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:
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
if amount <= 0:
@@ -150,36 +186,46 @@ class DeribitTrader(DeribitRead):
- target ~0 -> close();
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
- 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)
if abs(target_notional_usd - cur) < min_usd:
return []
fills: list[Fill] = []
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
if crossing: # flip: flatta, poi riparti da zero
f = self.close(instrument, label="book-flip-close")
if 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")
try:
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
if crossing: # flip: flatta, poi riparti da zero
f = self.close(instrument, label="book-flip-close")
if 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
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
except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
if fills:
raise RebalanceParziale(fills, e) from e
raise
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
@@ -194,10 +240,20 @@ class DeribitTrader(DeribitRead):
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:
"""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;
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
Stati possibili (`dict["state"]`):
- `"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
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
@@ -216,8 +272,11 @@ class DeribitTrader(DeribitRead):
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.
"""
pos = self.position_usd(instrument)
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
try:
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:
for o in brackets:
self.cancel_order(o.get("order_id"))
+49 -13
View File
@@ -248,12 +248,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
+ (f": {d['nota']}" if d["nota"] 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)
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
# (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
# cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
# 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",
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
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"],
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
delta=d["delta"],
@@ -376,16 +390,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"])
MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9)
def salute(con, giorno: date) -> dict:
"""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
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)
adesso = datetime.now(timezone.utc)
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)
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))
@@ -591,16 +612,19 @@ def lettura(con, voce: dict) -> list[dict]:
# 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)
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)
picco = max(r["equity"] for r in eq)
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s]
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s)
# (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
# prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
# 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
# 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)
if rilevante and g7:
quota = (e1 - g7[0]["equity"] - mv7) / tot
if rilevante and base7 is not None:
quota = (e1 - base7["equity"] - mv7) / tot
if quota > 0.60:
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
"e' un evento.")
@@ -609,10 +633,22 @@ def lettura(con, voce: dict) -> list[dict]:
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
if picco > 0:
dd = (e1 - picco) / picco
# (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
# 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:
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")
# --- volatilita' implicita contro realizzata ---------------------------------------------
+20 -7
View File
@@ -95,15 +95,20 @@ def _positions(client):
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
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,
-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:
eq = float(client.account_summary("USDE").get("equity") or 0)
except Exception:
return 0.0, ""
except Exception as e:
return None, f"USDE illeggibile: {type(e).__name__}: {e}"
if eq <= 0:
return 0.0, ""
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)
if eq > 1:
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:
return eq + usde_usd, f"mainnet USDC + {usde_note}"
return eq, "mainnet USDC"
@@ -133,7 +140,8 @@ def _equity(client, marks):
if any_ok and tot > 1:
return tot, "mainnet coin-margined"
# 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"
@@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
eq_basis = ("override" if equity_override is not None
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
# 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} "
f"invece del conto reale") if (equity_override is None and client is not None
and not real_eq) else None
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
return dict(
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,
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
assets=assets, orders=orders, live_trades=live_trades,
+45 -25
View File
@@ -262,31 +262,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]:
"""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
lotti: dict[str, deque] = defaultdict(deque)
lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno]
rts: list[dict] = []
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
if f.side == "buy":
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
else:
resto = f.qty
while resto > 1e-12 and lotti[f.asset]:
lq, lp, lts, lfee_u = lotti[f.asset][0]
usa = min(resto, lq)
lordo = usa * (f.price - lp)
quota_fee = usa * (lfee_u + fee_u)
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,
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
lq -= usa; resto -= usa
if lq <= 1e-12:
lotti[f.asset].popleft()
else:
lotti[f.asset][0][0] = lq
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
segno_fill = 1 if f.side == "buy" else -1
dq = lotti[f.asset]
resto = f.qty
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
lq, lp, lts, lfee_u, lseg = dq[0]
usa = min(resto, lq)
# long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
quota_fee = usa * (lfee_u + fee_u)
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,
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
direzione=("long" if lseg == 1 else "short")))
lq -= usa; resto -= usa
if lq <= 1e-12:
dq.popleft()
else:
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
@@ -332,8 +344,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]])
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
`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"],
price=r["price"], fee=r["fee"]) for r in righe]
rts, _ = fifo_roundtrips(fills)
@@ -358,7 +374,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None):
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"],
price=r["price"], fee=r["fee"]) for r in righe]
_, aperti = fifo_roundtrips(fills)
@@ -366,7 +383,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
for a, lotti in aperti.items():
q = sum(x["qty"] 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
+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']})")
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
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
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
+15
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"
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):
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
import analista as CLI
+44 -13
View File
@@ -415,7 +415,10 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
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
mod = carica_script("scripts/live/book_execute.py")
@@ -426,17 +429,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)],
orders=[],
)
class _TraderOk:
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
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",
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
mod._run()
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
try:
mod._run()
except SystemExit:
pass
out = capsys.readouterr().out
assert "SKH FEED ERRORE" in out # stampato nel log
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
assert "SKH FEED ERRORE" in out # stampato nel log, sempre
assert len(alerts) == 1 # UNA sola notify, non due
assert "solo protezione" in alerts[0][0].lower()
# ---------------------------------------------------------------------------
@@ -535,8 +548,11 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
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):
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
"""(#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
mod = carica_script("scripts/live/book_execute.py")
@@ -551,18 +567,33 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
alerts = []
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 position_usd(self, inst): return 0.0
trader = FakeDT()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
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",
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
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
assert "EQUITY FALLBACK" in out # avvisato
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)
assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta)
# (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)"
# 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
+551
View File
@@ -0,0 +1,551 @@
"""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
# ---------------------------------------------------------------------------
# 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
+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
+51
View File
@@ -162,3 +162,54 @@ def test_un_fill_non_misurabile_e_None_non_zero():
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)
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
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"]
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
+8 -4
View File
@@ -31,12 +31,16 @@ def db(tmp_path):
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."""
oggi = datetime.now(timezone.utc).date()
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
feed_skh_min=0))
class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
@classmethod
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)
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
+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"]
+8 -2
View File
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
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), {})
assert eq == 2056.0 and src == "mainnet USDC"
assert eq is None
assert "USDE illeggibile" in src and "RuntimeError" in src
+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)]
rts, aperti = T.fifo_roundtrips(fills)
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():
@@ -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'])`.
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()
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
+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
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
from datetime import datetime, timezone
def q(giorno, v):
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
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
def test_quota_da_decidere_e_stata_rimossa():
"""#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
giorno finche' non la si toglie a mano."""
assert not hasattr(W, "quota_da_decidere")
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
# ------------------------------ 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}]
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
# =============================================================================================
# 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"}
+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."""
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
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"})