22 Commits

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:17:01 +00:00
Adriano 12291709ed revisione, analista: taglio Telegram sul testo grezzo, prima di escape/markup (#35 C13)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:15:22 +00:00
Adriano 8e75e9c9a7 sleeves+edge_watch: pesi derivati da book (import locale), titolo kill corretto (#35 B12)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:15:07 +00:00
Adriano 13c136cf4b tradesdb: docstring corretto (jsonl ha l'ora vera dal 24/08, non piu' la barra); trades_db sync confronta i conteggi jsonl/DB e notifica una volta per episodio (#37 C11, #35 A9) 2026-09-27 15:14:04 +00:00
Adriano e82f2ee163 venue_probe: atteso hardcoded False in diagnose, l'unico chiamante non cade mai nello slot (#35 B11)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:29 +00:00
Adriano d80323ff59 analista: testata mostra trading_giorno (non l'equity grezza) e 'nel giorno', non 'oggi' (#35 C8)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:20 +00:00
Adriano d59aea7a0a monitor_health: giro orario dei sorveglianti da 3h con marcatore gia' detto (#30)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:11:10 +00:00
Adriano 31ec767ff5 telegram_daily: soglia di accensione generale su k flip, non il minimo fisso (#35 C3)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:10:04 +00:00
Adriano 95cfb396e7 fee_watch: realized_fee_bps sempre calcolata, divergenza da tier notificata una volta per episodio (#37 B9) 2026-09-27 15:06:40 +00:00
Adriano 604fde689e cuscino_watch: riconverti distingue parziale/fallita rileggendo la quota (#35 B10)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:03:02 +00:00
33 changed files with 1733 additions and 177 deletions
+8 -2
View File
@@ -35,10 +35,16 @@ mkdir -p logs
# partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2 # partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2
# = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py. # = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py.
uv run python scripts/live/venue_watch.py --quiet || true uv run python scripts/live/venue_watch.py --quiet || true
uv run python scripts/live/book_execute.py --execute # LOCK SUL CONTO (#36): cuscino_watch (cron_cuscino.sh, :53) puo' mandare ordini spot USDE
# sullo stesso conto — separati solo dal tempo, giri fino a 659s misurati. `flock -n` (mai
# `-w`, un SIGTERM a meta' ordine perde la riga di log): se cuscino_watch tiene il lock questo
# giro si SALTA (rc 75) invece di sovrapporsi. Niente kill/timeout sul comando.
flock -n -E 75 logs/.conto.lock uv run python scripts/live/book_execute.py --execute; rc=$?
[ $rc -eq 75 ] && echo " book: SALTATO — conto occupato da un altro giro (logs/.conto.lock)"
# Libro di bordo: materializza in data/live/trades.db (che il backup COPRE) l'ora vera dei # Libro di bordo: materializza in data/live/trades.db (che il backup COPRE) l'ora vera dei
# fill, che altrimenti vive solo in questo log — gitignored, fuori dal backup e ruotabile. # fill, che altrimenti vive solo in questo log — gitignored, fuori dal backup e ruotabile.
# Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte. # Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte. Fuori dal lock:
# e' sola lettura, non tocca il conto.
uv run python scripts/live/trades_db.py --sync --quiet || true uv run python scripts/live/trades_db.py --sync --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') =====" echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_book.log 2>&1 } >> logs/cron_book.log 2>&1
+9 -1
View File
@@ -5,11 +5,19 @@
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le # PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo # guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
# stesso del libro (execution_enabled in config/live.json). # stesso del libro (execution_enabled in config/live.json).
# LOCK SUL CONTO (#36): cuscino_watch e book_execute (cron_book.sh, :47) sono separati solo dal
# tempo e possono mandare ordini sullo stesso conto — giri fino a 659s sono stati misurati.
# `flock -n` (mai `-w`: un'attesa farebbe scivolare il giro orario) su logs/.conto.lock: se
# book_execute lo tiene, questo giro si SALTA (rc 75) invece di sovrapporsi.
export PATH="/home/adriano/.local/bin:$PATH" export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1 cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs mkdir -p logs
{ {
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino =====" echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
uv run python scripts/live/cuscino_watch.py --quiet || true flock -n -E 75 logs/.conto.lock uv run python scripts/live/cuscino_watch.py --quiet; rc=$?
[ $rc -eq 75 ] && echo " cuscino: SALTATO — conto occupato (logs/.conto.lock)"
# (#30) giro orario dei sorveglianti a max_age<=6h: notifica solo il NUOVO, marcatore in
# data/live/monitor_health_detto.json. Fuori dal lock: e' sola lettura, non tocca il conto.
uv run python scripts/live/monitor_health.py --quiet --solo-orari || true
echo "===== done $(date -u '+%H:%M:%SZ') =====" echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_cuscino.log 2>&1 } >> logs/cron_cuscino.log 2>&1
+17 -10
View File
@@ -85,14 +85,16 @@ def log_event(rec: dict):
f.write(json.dumps(rec) + "\n") f.write(json.dumps(rec) + "\n")
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int): def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int,
pos_after: float | None = None):
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la """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 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 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`). 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 `pos_after` arriva dal chiamante (#39 — regressione di #23): nel ramo normale di `_run` si
aver registrato tutti i fill, in `_run` — vedi il commento li'.""" legge SUBITO dopo il rebalance, in un try che isola la lettura dalla registrazione dei fill;
resta None quando la lettura solleva o quando il passo e' un `RebalanceParziale` (a meta')."""
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} " 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})") 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 # `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
@@ -102,7 +104,7 @@ def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: floa
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"), log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act, bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc, 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, verified=f.verified, notes=f.notes, net_target=net, pos_after=pos_after,
tp_frac=tp_frac, skh_sign=skh_sign)) 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), 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)) fee=round(f.fee_usdc, 5), net=round(net, 0))
@@ -332,17 +334,22 @@ def _run():
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"], _registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]) tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
raise rp.causa raise rp.causa
for f in fills: # newpos si legge SUBITO dopo il rebalance, in un try che ISOLA la lettura dalla
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"], # registrazione dei fill (#39, regressione di #23: dal merge di #23 pos_after era
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]) # sempre None, perche' la lettura restava un side-effect informativo dopo il ciclo
# newpos e' un RECONCILE informativo, letto DOPO aver registrato i fill (#23): se # e il suo risultato non arrivava piu' a `_registra_fill`). Se solleva, i fill si
# questa lettura solleva, i fill sono gia' salvi (sopra), e non e' un guasto del # registrano comunque, con pos_after=None — il vincolo #23 (un fill non si perde
# giro (isolamento) -> si segnala e si continua (disaster-SL sotto, comunque). # per un reconcile mancato) resta: il try isola solo la LETTURA, non la scrittura.
try: try:
newpos = trader.position_usd(inst) newpos = trader.position_usd(inst)
pos_after = newpos
print(f" reconcile: pos ${newpos:,.0f}") print(f" reconcile: pos ${newpos:,.0f}")
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
pos_after = None
print(f" reconcile: pos illeggibile ({type(e).__name__})") print(f" reconcile: pos illeggibile ({type(e).__name__})")
for f in 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"], pos_after=pos_after)
if do_execute: if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short) ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
stato = ds.get("state") stato = ds.get("state")
+61 -12
View File
@@ -80,7 +80,9 @@ CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️). # dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️).
RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore
RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min TIMEOUT_CONVERT_S = 900 # usde_convert: 12s di pausa fra ordini + le GET per giro (~14s/giro
# coi 12s di pausa); 60 ordini ~= 840s: il tetto e' stretto sul caso
# massimo, non largo come "60 ordini = 12 min" lasciava credere (#35 B10)
def leggi(path: Path = STATE) -> list[dict]: def leggi(path: Path = STATE) -> list[dict]:
@@ -158,17 +160,56 @@ def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: b
return True, "" return True, ""
def _leggi_quota_usde() -> float | None:
"""Quota USDE sul totale, STESSA formula di usde_watch.py:275 (usd/(usd+eq_usdc)), stessi
mattoni di main() qui sotto (_safe_client, account_summary, U.prezzo, U.valuta): nessuna
lettura nuova. None se il conto non e' leggibile — chi chiama tratta None come "non lo so",
non come zero (#35 B10)."""
client = _safe_client()
if client is None:
return None
try:
eq_usdc = float(client.account_summary("USDC")["equity"])
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
except Exception:
return None
px, _ = U.prezzo(client)
usd, _ = U.valuta(eq_usde, px)
try:
return round(usd / (usd + eq_usdc), 4)
except ZeroDivisionError:
return None
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict: def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).""" """Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).
Rilegge la quota USDE prima e dopo il subprocess, ANCHE su timeout o eccezione: un rc!=0 con
la quota mossa e' una conversione PARZIALE (fill gia' avvenuti prima del kill a timeout_s),
non "FALLITA" come diceva `ok = rc==0` (#35 B10). `esito` in "ok" | "parziale" | "fallita"* —
"ok" solo per compatibilita' resta anche in `ok` (bool)."""
quota_prima = _leggi_quota_usde()
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"] cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
try: try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT)) r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
rc = r.returncode
coda = (r.stdout + r.stderr).strip().splitlines()[-12:] coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:])) rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"]
except Exception as e: # noqa: BLE001 — si registra, non si ingoia except Exception as e: # noqa: BLE001 — si registra, non si ingoia
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:])) rc, coda = None, [f"{type(e).__name__}: {e}"]
quota_dopo = _leggi_quota_usde()
if rc == 0:
esito = "ok"
elif quota_dopo is None:
esito = "fallita (quota dopo non leggibile)"
else:
mossa = quota_prima is not None and abs(quota_dopo - quota_prima) > 0.005
esito = "parziale" if mossa else "fallita"
return dict(rc=rc, ok=esito == "ok", esito=esito, quota_prima=quota_prima, quota_dopo=quota_dopo,
coda=coda, cmd=" ".join(cmd[1:]))
def _safe_client(): def _safe_client():
@@ -239,17 +280,23 @@ def main() -> int:
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})") motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
else: else:
esito = riconverti(quota) ris = riconverti(quota)
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito) rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris)
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio --- # --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
# ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al # ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al
# primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'. # primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'.
def _esito_azione(r: dict) -> str: def _esito_azione(r: dict) -> str:
if r.get("tentata"): if not r.get("tentata"):
return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA") return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
+ f" (quota bersaglio {r['quota']:.0%})") esito = r.get("esito", "ok" if r.get("ok") else "fallita")
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}" if esito == "parziale":
qp, qd = r.get("quota_prima"), r.get("quota_dopo")
qp_s = f"{qp:.0%}" if qp is not None else "n/d"
qd_s = f"{qd:.0%}" if qd is not None else "n/d"
return f"{r['verso']} PARZIALE: quota da {qp_s} a {qd_s}"
return (f"{r['verso']} " + ("ESEGUITA" if esito == "ok" else "FALLITA")
+ f" (quota bersaglio {r['quota']:.0%})")
if transizione(prev, stato): if transizione(prev, stato):
if secco: if secco:
@@ -275,7 +322,9 @@ def main() -> int:
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco: elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
# non e' una transizione ma e' un ordine mandato: si dice sempre # non e' una transizione ma e' un ordine mandato: si dice sempre
r = rec["riconversione"] r = rec["riconversione"]
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"), esito = r.get("esito", "ok" if r.get("ok") else "fallita")
verbo = {"ok": "eseguita", "parziale": "PARZIALE"}.get(esito, "FALLITA")
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} {verbo}",
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])}) {"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
if rec["allertato"] is False and not secco: if rec["allertato"] is False and not secco:
from src.live.notifier import ultimo_errore from src.live.notifier import ultimo_errore
+1 -1
View File
@@ -140,7 +140,7 @@ def main() -> int:
print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · " print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · "
f"fallimenti consecutivi in corso: {r['consec_fail']}") f"fallimenti consecutivi in corso: {r['consec_fail']}")
if r["alerts"]: if r["alerts"]:
notify("🚨 EDGE WATCH — criterio di kill scattato sul book LIVE", notify("🚨 EDGE WATCH — criterio di kill scattato sul book (serie backtest dei sleeve, non il P&L eseguito)",
{f"alert {i+1}": a for i, a in enumerate(r["alerts"])}) {f"alert {i+1}": a for i, a in enumerate(r["alerts"])})
for a in r["alerts"]: for a in r["alerts"]:
print(f" ALERT: {a}") print(f" ALERT: {a}")
+46 -6
View File
@@ -79,6 +79,7 @@ SOGLIA_OK = 5.0 # <= -> nessuna azione
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01 SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
D_CAGR_PER_BPS = -0.0023 D_CAGR_PER_BPS = -0.0023
SOGLIA_DIVERGENZA_BPS = 1.0 # #37 B9: |fee realizzata - tier pubblico| oltre questo -> notify
def fetch_fees(instrument: str) -> dict: def fetch_fees(instrument: str) -> dict:
@@ -209,14 +210,30 @@ def run() -> dict:
changes = diff_vs(prev, cur) changes = diff_vs(prev, cur)
worst = max(c["taker_bps"] for c in cur.values()) worst = max(c["taker_bps"] for c in cur.values())
lvl, why = verdict(worst) lvl, why = verdict(worst)
# issue #37 (B9). realized_fee_bps() girava SOLO dentro il blocco di stampa: in cron
# (--quiet, il caso normale) non girava mai e una fee realmente pagata diversa dal tier
# pubblico non avvisava nessuno. Qui si calcola SEMPRE ed entra nel record del giro
# (registra_giro, sotto). {} -> None: "non misurata" (nessun trade recente), mai "zero".
real = realized_fee_bps() or None
divergenze = []
if real:
for ins, bps in real.items():
tier = cur.get(ins, {}).get("taker_bps")
if tier is None:
continue
d = bps - tier
if abs(d) > SOGLIA_DIVERGENZA_BPS:
divergenze.append(
f"{ins}: realizzata {bps:.2f} bps vs tier pubblico {tier:.2f} ({d:+.2f})")
return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why, return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why,
first_read=not prev) first_read=not prev, realized=real, divergenze=divergenze,
divergenza_gia_detta=bool(prev.get("divergenza_detta")))
def main() -> int: def main() -> int:
r = run() r = run()
quiet = "--quiet" in sys.argv quiet = "--quiet" in sys.argv
interessante = bool(r["changes"]) or r["level"] != "OK" interessante = bool(r["changes"]) or r["level"] != "OK" or bool(r.get("divergenze"))
if not quiet or interessante: if not quiet or interessante:
print("=" * 86) print("=" * 86)
@@ -232,7 +249,7 @@ def main() -> int:
print(" (prima lettura: stato inizializzato, nessun confronto possibile)") print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
for c in r["changes"]: for c in r["changes"]:
print(f" CAMBIO: {c}") print(f" CAMBIO: {c}")
real = realized_fee_bps() real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9)
if real: if real:
print("\n cross-check sui trade REALI del conto (fonte autorevole):") print("\n cross-check sui trade REALI del conto (fonte autorevole):")
for ins, bps in real.items(): for ins, bps in real.items():
@@ -240,7 +257,8 @@ def main() -> int:
nota = "" nota = ""
if tier is not None: if tier is not None:
d = bps - tier d = bps - tier
nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "") nota = (f" (tier taker {tier:.2f} → {d:+.2f})"
+ (" ⚠️ DIVERGE" if abs(d) > SOGLIA_DIVERGENZA_BPS else ""))
print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}") print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}")
else: else:
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)") print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
@@ -257,9 +275,31 @@ def main() -> int:
}) })
if not inviato and (not quiet or interessante): if not inviato and (not quiet or interessante):
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta") print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
# issue #37 (B9): fee realmente pagata che diverge dal tier pubblico atteso -> notify UNA
# volta per episodio. Marcatore "divergenza_detta" nello stesso state.json (stessa regola di
# B8: si scrive solo se l'invio e' riuscito), azzerato quando la divergenza torna sotto
# soglia. E' un evento indipendente dal cambio di tier: se qui serve un invio e quello di B8
# e' appena fallito, save_state sotto non gira (stessa gate di B8) e il marcatore non si
# scrive — al giro dopo si ridice, ridondante ma mai persa, la stessa scelta di B8.
divergenze = r.get("divergenze") or []
gia_detta = bool(r.get("divergenza_gia_detta"))
marcatore = gia_detta
if divergenze and not gia_detta:
inviata_div = notify("💸 FEE WATCH — fee realizzata diverge dal tier pubblico", {
f"divergenza {i+1}": d for i, d in enumerate(divergenze)
})
if inviata_div:
marcatore = True
elif not quiet or interessante:
print(" ⚠️ invio divergenza FALLITO — marcatore non scritto: il prossimo giro ritenta")
elif not divergenze:
marcatore = False
stato_da_salvare = dict(r["current"])
stato_da_salvare["divergenza_detta"] = marcatore
if not serve_invio or inviato: if not serve_invio or inviato:
save_state(r["current"]) save_state(stato_da_salvare)
registra_giro(RUNS, level=r["level"]) registra_giro(RUNS, level=r["level"], realized=r.get("realized"))
return 2 if r["level"] == "AZIONE" else 0 return 2 if r["level"] == "AZIONE" else 0
+76 -3
View File
@@ -10,20 +10,91 @@ monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSP
uv run python scripts/live/monitor_health.py # report uv run python scripts/live/monitor_health.py # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
# max_age<=6h (cuscino_watch, venue_watch, collect_chain), notifica solo il NUOVO
# rispetto a data/live/monitor_health_detto.json — pensato per girare ogni ora
# (cron_cuscino.sh), non per sostituire il giro giornaliero completo.
""" """
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402 from src.live.monitor_health import ( # noqa: E402
MIN_COVERAGE,
alert_nuovi,
alerts_from,
check_all,
marcatore_aggiornato,
monitors_orari,
)
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify # noqa: E402
# (#30) marcatore "gia' detto" del giro orario: {"detti": [<chiave alert>], "ts": ...}. Vive fuori
# da src/ perche' e' I/O, non parte della misura pura.
DETTO = ROOT / "data" / "live" / "monitor_health_detto.json"
def _carica_detti(path: Path = DETTO) -> set[str]:
if not path.exists():
return set()
try:
return set(json.loads(path.read_text()).get("detti", []))
except Exception:
return set()
def _salva_detti(detti: set[str], path: Path = DETTO) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(
{"detti": sorted(detti), "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
indent=2))
def main_orari() -> int:
"""(#30) Giro orario: solo i sorveglianti a max_age<=6h, notifica solo il NUOVO rispetto al
marcatore. Exit 2 se c'e' un alert (anche gia' detto), 0 altrimenti — il giro giornaliero
resta l'unico posto dove "nessun alert nuovo" vuol dire davvero "tutto OK"."""
quiet = "--quiet" in sys.argv
rows = check_all(ROOT, monitors=monitors_orari())
alerts = alerts_from(rows)
detti = _carica_detti(DETTO) # DETTO letto qui, non nel default del parametro (monkeypatch)
nuovi = alert_nuovi(rows, detti)
aggiornato = marcatore_aggiornato(rows, detti)
if not quiet or alerts:
print("=" * 92)
print(" MONITOR HEALTH — giro orario (sorveglianti con max_age <= 6h)")
print("=" * 92)
for r in rows:
cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-"
age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-"
print(f" {r['name']:<20}{r['status']:>10}{age:>13}{cov:>10}")
inviato = True
if nuovi:
msgs = alerts_from(nuovi)
inviato = notify("🩺 MONITOR HEALTH — un sorvegliante orario non sta registrando",
{f"alert {i+1}": m for i, m in enumerate(msgs)})
for m in msgs:
print(f" ALERT (nuovo): {m}")
if not inviato:
print(" ⚠️ invio FALLITO — marcatore NON aggiornato: il prossimo giro ritenta")
if not nuovi or inviato:
_salva_detti(aggiornato, DETTO)
return 2 if alerts else 0
def main() -> int: def main() -> int:
if "--solo-orari" in sys.argv:
return main_orari()
rows = check_all(ROOT) rows = check_all(ROOT)
alerts = alerts_from(rows) alerts = alerts_from(rows)
@@ -55,15 +126,17 @@ def main() -> int:
return 0 return 0
USO = """uso: monitor_health.py [--quiet] USO = """uso: monitor_health.py [--quiet] [--solo-orari]
monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla. monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
uv run python scripts/live/monitor_health.py # report uv run python scripts/live/monitor_health.py # report
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
max_age<=6h, notifica solo il NUOVO rispetto al marcatore
Dettaglio nel docstring in testa al file.""" Dettaglio nel docstring in testa al file."""
if __name__ == "__main__": if __name__ == "__main__":
from src.live.cli import valida from src.live.cli import valida
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=()) valida("monitor_health.py", USO, flag=("--quiet", "--solo-orari"), con_valore=())
raise SystemExit(main()) raise SystemExit(main())
+88 -17
View File
@@ -25,17 +25,63 @@ import pandas as pd
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.monitor_health import MONITORS
from src.live.notifier import send from src.live.notifier import send
HORIZONS = (30, 90, 180) HORIZONS = (30, 90, 180)
GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"),
("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")]
def _gate_date(nome_monitor: str, cablata: date) -> date:
"""(#35 C14) la data del gate viene da monitor_health.MONITORS (`MonitorSpec.gate`), la
STESSA tabella che "METTE A RISCHIO il gate" negli alert dei sorveglianti: una fonte, non
una seconda dichiarazione duplicata di CLAUDE.md §4. Il campo e' prosa libera
("YYYY-MM-DD — descrizione"): si legge la data solo se e' nella forma attesa. `cablata' e'
rete di sicurezza se il monitor sparisse o il campo cambiasse forma — non deve succedere per
i due gate qui sotto, oggi allineati (verificato a mano contro CLAUDE.md §4)."""
for m in MONITORS:
if m.name == nome_monitor and m.gate:
try:
return date.fromisoformat(m.gate[:10])
except ValueError:
break
return cablata
GATES = [("STATARB-RESID", _gate_date("paper_statarb", date(2026, 9, 27)),
"data/paper_statarb/returns.jsonl", "net_modeled"),
("XSR01", _gate_date("paper_xsr", date(2026, 10, 23)),
"data/paper_xsr/returns.jsonl", "net_modeled")]
def _fmt_usd(x: float) -> str: def _fmt_usd(x: float) -> str:
return f"${x:,.2f}" return f"${x:,.2f}"
def _soglia_accensione(segni: list[int], neg_refs: list[float]) -> float | None:
"""(#35 C3) Quale riferimento, superato, accende TP01 (mean(segni) > 0)?
I segni sono sempre in {-1,+1}, mai 0 (trend_state li costruisce con `1 if px>refs[h]
else -1`: nessun pareggio possibile). Con k segni negativi da far diventare positivi, la
somma cresce di 2 per flip: serve il piu' piccolo k tale che sum(segni)+2k > 0. La soglia
e' il k-esimo riferimento negativo piu' basso (k=1 -> il minimo, come prima del fix; k=2 ->
il secondo, ecc.) — non sempre il minimo: con TUTTI i riferimenti negativi, superare solo il
piu' basso lascia la media <= 0 e TP01 a 0 (bug segnalato in #35 C3).
None se la media e' gia' positiva (nessuna soglia da dare) o se k supera il numero di
riferimenti negativi disponibili (non dovrebbe accadere: con n segni, flippare tutti gli n
negativi porta comunque la somma a +n > 0, quindi k <= len(neg_refs) sempre — resta una
guardia difensiva, non un caso noto)."""
s = sum(segni)
if s > 0 or not neg_refs:
return None
k = 0
while s + 2 * k <= 0:
k += 1
if k > len(neg_refs):
return None
return sorted(neg_refs)[k - 1]
def trend_state() -> list[str]: def trend_state() -> list[str]:
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi). """Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
@@ -59,32 +105,51 @@ def trend_state() -> list[str]:
tgt = max(0.0, m) tgt = max(0.0, m)
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \ line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
f" → TP01 {tgt:.2f}" f" → TP01 {tgt:.2f}"
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0] if tgt == 0.0:
if neg and tgt == 0.0: r = _soglia_accensione(list(sg.values()), [refs[h] for h in HORIZONS if sg[h] < 0])
h, r = min(neg, key=lambda t: t[1]) if r is not None:
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)" line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
out.append(line) out.append(line)
except Exception as e: except Exception as e:
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})") out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
return out return out
def book_state() -> list[str]: def book_state() -> tuple[list[str], bool | None]:
"""Conto reale, posizioni e target netto correnti. Sola lettura.""" """Conto reale, posizioni e target netto correnti. Sola lettura.
(#35 C14) Ritorna anche se il libro e' DAVVERO flat (nessuna posizione, nessun target: stessa
condizione che questa funzione gia' usa riga per riga per stampare "flat" invece di "pos ->
target"): serve a `build()` per decidere se l'header "Perche' non opera" e' vero o
fuorviante, SENZA una lettura nuova — None se lo stato del libro non e' leggibile (in quel
caso non sappiamo dire se opera)."""
try: try:
from src.live.book import book_report from src.live.book import book_report
r = book_report() r = book_report()
eq = r.get("equity") eq = r.get("equity")
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"] lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
flat = True
for a in r.get("assets", []): for a in r.get("assets", []):
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0) pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}" is_flat = abs(pos) < 1 and abs(net) < 1
flat = flat and is_flat
stato = "flat" if is_flat else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})") lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
if r.get("skh_error"): if r.get("skh_error"):
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}") lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
return lines return lines, flat
except Exception as e: except Exception as e:
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"] return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"], None
def _fmt_giorni(giorni: int) -> str:
"""(#35 C14) tre casi distinti, non un segno "−" cablato davanti a un intero che puo' essere
negativo: con giorni=-1 quel cablaggio stampava "(−-1g)", un doppio segno illeggibile."""
if giorni > 0:
return f"-{giorni}g"
if giorni == 0:
return "oggi"
return f"scaduto da {-giorni}g"
def monitors() -> list[str]: def monitors() -> list[str]:
@@ -92,20 +157,21 @@ def monitors() -> list[str]:
for name, dec, path, key in GATES: for name, dec, path, key in GATES:
p = ROOT / path p = ROOT / path
giorni = (dec - date.today()).days giorni = (dec - date.today()).days
gate_txt = f"gate {dec} ({_fmt_giorni(giorni)})"
if not p.exists(): if not p.exists():
out.append(f" {name}: in attesa della prima barra · gate {dec} (−{giorni}g)") out.append(f" {name}: in attesa della prima barra · {gate_txt}")
continue continue
try: try:
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()] rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
if not rows: if not rows:
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)") out.append(f" {name}: 0 barre · {gate_txt}")
continue continue
r = np.array([x[key] for x in rows], float) r = np.array([x[key] for x in rows], float)
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0 sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
tot = float(np.prod(1 + r) - 1) * 100 tot = float(np.prod(1 + r) - 1) * 100
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · gate {dec} (−{giorni}g)") out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · {gate_txt}")
except Exception: except Exception:
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)") out.append(f" {name}: log illeggibile · {gate_txt}")
return out return out
@@ -129,8 +195,13 @@ def last_trade() -> str:
def build() -> str: def build() -> str:
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC") oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"] L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
L += book_state() righe_conto, flat = book_state()
L += ["", "<b>Perche' non opera</b>"] L += righe_conto
# (#35 C14) "Perche' non opera" e' vero solo se il libro e' DAVVERO flat (nessuna posizione,
# nessun target: gia' letto sopra da book_state, nessuna lettura nuova). Se non e' flat (ha
# posizioni o un target diverso da zero) o non si sa (book non leggibile), l'header fisso
# mentirebbe: si usa un titolo neutro.
L += ["", "<b>Perche' non opera</b>" if flat else "<b>Stato trend</b>"]
L += trend_state() L += trend_state()
L += ["", "<b>Ordini</b>", last_trade()] L += ["", "<b>Ordini</b>", last_trade()]
L += ["", "<b>Monitor in osservazione</b>"] L += ["", "<b>Monitor in osservazione</b>"]
+39 -3
View File
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402 from src.live import tradesdb as T # noqa: E402
from src.live.notifier import notify # noqa: E402
def sync(verbose: bool = True) -> dict: def sync(verbose: bool = True) -> dict:
@@ -25,16 +26,51 @@ def sync(verbose: bool = True) -> dict:
fills = [f for r in runs for f in r.fills] fills = [f for r in runs for f in r.fills]
n_f = T.upsert_fills(con, fills) n_f = T.upsert_fills(con, fills)
n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None]) n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None])
# #40 (TradingBook#11): arricchimento PRIMA dei round-trip, cosi' il FIFO vede gia' i prezzi
# pieni e le ore vere delle righe abbinate al jsonl, non quelle (arrotondate, di inizio giro)
# di cron_book.log. `righe_jsonl` si legge una volta sola e serve anche al controllo di
# conteggio (#37 C11) qui sotto.
righe_jsonl = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
n_arricchiti, n_ambigui = T.arricchisci_da_jsonl(con, righe_jsonl)
n_r = T.rebuild_roundtrips(con) n_r = T.rebuild_roundtrips(con)
if runs: if runs:
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc) T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
T.set_meta(con, "ultimo_sync", T.ora()) T.set_meta(con, "ultimo_sync", T.ora())
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"] tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"]
# issue #37 (C11). Dal 2026-08-24T01:07Z (T.CUTOFF_TS_VERO) book_executions.jsonl porta l'ora
# VERA per-fill (docstring di tradesdb.py). Un conteggio diverso in quella finestra segnala
# che una delle due fonti sta perdendo fill — questo controllo resta indipendente
# dall'arricchimento sopra (che abbina riga per riga, non conta): un conteggio uguale non
# esclude un abbinamento ambiguo, e viceversa. Notify UNA volta per episodio: marcatore in
# `meta` (stessa regola di fee_watch #37 B9 — scritto solo se l'invio riesce, azzerato
# quando i conteggi tornano uguali).
n_jsonl_dal = sum(1 for r in righe_jsonl if str(r.get("ts_utc", "")) >= T.CUTOFF_TS_VERO)
n_db_dal = con.execute("SELECT COUNT(*) c FROM fills WHERE ts_utc >= ?",
(T.CUTOFF_TS_VERO,)).fetchone()["c"]
diverge = n_jsonl_dal != n_db_dal
gia_detto = T.get_meta(con, "conteggio_diverso_detto") == "1"
if diverge:
msg = f"⚠️ trades.db: {n_jsonl_dal} fill nel jsonl, {n_db_dal} nel DB dal 24/08"
if verbose:
print(f" {msg}")
if not gia_detto:
if notify("⚠️ trades.db — conteggio fill diverso dal jsonl dal 24/08",
{"fill nel jsonl": n_jsonl_dal, "fill nel DB": n_db_dal}):
T.set_meta(con, "conteggio_diverso_detto", "1")
elif gia_detto:
T.set_meta(con, "conteggio_diverso_detto", "0")
if verbose: if verbose:
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}") print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
print(f" arricchiti dal jsonl: {n_arricchiti} | ambigui: {n_ambigui}")
con.close() con.close()
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot) return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot,
jsonl_dal_24_08=n_jsonl_dal, db_dal_24_08=n_db_dal,
arricchiti=n_arricchiti, ambigui=n_ambigui)
def reconcile() -> None: def reconcile() -> None:
@@ -42,8 +78,8 @@ def reconcile() -> None:
fills = [f for r in runs for f in r.fills] fills = [f for r in runs for f in r.fills]
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else [] righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
rec = T.reconcile(fills, righe) rec = T.reconcile(fills, righe)
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)") print(f"\n cron_book.log : {len(fills)} fill (ora di inizio giro)")
print(f" book_executions : {len(righe)} righe (senza ora)") print(f" book_executions : {len(righe)} righe (ora VERA per-fill dal 24/08, #37 C11)")
print(f" concordano : {len(rec['ok'])}") print(f" concordano : {len(rec['ok'])}")
print(f" solo nel log : {len(rec['solo_log'])}") print(f" solo nel log : {len(rec['solo_log'])}")
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}") print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
+12
View File
@@ -61,6 +61,18 @@ reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa
uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch) uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine
LOCK SUL CONTO (#36). Un lancio a MANO con --esegui condivide il conto con cuscino_watch e
book_execute (cron_cuscino.sh/cron_book.sh, entrambi dentro `flock -n logs/.conto.lock`): senza
lo stesso lock un ordine a mano nello stesso minuto del cron puo' convertire due volte verso
bersagli diversi (limite D5 gia' dichiarato sopra). Si lancia quindi con:
flock logs/.conto.lock uv run python scripts/live/usde_convert.py --quota 0.64 --esegui
⚠️ Qui NON senza `-n`: un lancio a mano puo' aspettare il turno invece di saltare. Questo script
stesso NON prende il lock (vedi cuscino_watch.py): se lo facesse e girasse come figlio di
cuscino_watch (che il lock lo tiene gia'), un secondo `flock -n` lo troverebbe occupato e
fallirebbe subito, per costruzione, ogni singola volta.
""" """
from __future__ import annotations from __future__ import annotations
+27 -7
View File
@@ -206,9 +206,22 @@ def testata(voce: dict) -> str:
p, lb = voce.get("pnl") or {}, voce.get("libro") or {} p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming") dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
eq = p.get("equity_fine") eq = p.get("equity_fine")
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: "oggi" e' ieri -> "nel giorno". E se
# ci sono movimenti di capitale CERTI (mov_giorno; bool() come journal.scorporo: assente o
# 0 -> nessun movimento) la cifra da mostrare e' trading_giorno (al netto), non l'equity
# grezza: altrimenti un versamento si legge come P&L (successo il 25/08, +$1.399 letti come
# profitto). Se trading_giorno non e' calcolabile si resta su delta_equity, ma con l'etichetta
# nuova: non e' "oggi", e non e' comunque il trading al netto.
dq_t = p.get("trading_giorno")
if p.get("mov_giorno") and dq_t is not None:
nota_dq = f" ({dq_t:+.2f} nel giorno, di cui {p['mov_giorno']:+.2f} movimenti)"
elif dq is not None:
nota_dq = f" ({dq:+.2f} nel giorno)"
else:
nota_dq = ""
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"] r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d") r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
+ ("" if dq is None else f" ({dq:+.2f} oggi)") + nota_dq
+ ("" if cum is None else f" · dall'arming {cum:+.2f}" + ("" if cum is None else f" · dall'arming {cum:+.2f}"
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading " + (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
f"{p['trading_da_arming']:+.2f})" f"{p['trading_da_arming']:+.2f})"
@@ -228,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None
""" """
testo = testata(voce) testo = testata(voce)
if analisi.strip(): if analisi.strip():
corpo = _b(analisi.strip()) grezzo = analisi.strip()
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
if stato == "sospetta": if stato == "sospetta":
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>" testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
testo += "\n\n" + corpo # (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **-><b>: un
# taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un <b>
# aperto, e Telegram rifiuta l'INTERO messaggio ("can't parse entities") — il taglio si
# perde tutto, non si accorcia. Il marcatore si aggiunge dopo, fuori dall'escape (e'
# testo nostro, senza `&`/`<`: non ha bisogno di escape).
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
budget = LIMITE_TELEGRAM - len(testo) - len("\n\n") - len(taglio)
tagliata = len(grezzo) > budget
if tagliata:
grezzo = grezzo[:max(budget, 0)]
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", _b(grezzo)) # il grassetto del giornale
testo += "\n\n" + corpo + (taglio if tagliata else "")
else: else:
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): " testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>") + _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
if len(testo) > LIMITE_TELEGRAM:
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
return testo return testo
+42 -5
View File
@@ -4,8 +4,8 @@ TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit e
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo. software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni Formula (preserva il budget di rischio per-asset di config (CAP sotto: `max_notional_per_asset_usd`
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): o `_frac`), split 75/25; coerente col blend di ritorni deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP ) net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
@@ -13,8 +13,8 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m) skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
E = equity reale del conto (fallback paper se offline) E = equity reale del conto (fallback paper se offline)
CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE CAP = cap notional per-asset. Fisso = `max_notional_per_asset_usd` di config, OPPURE
DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la DINAMICO = equity * `max_notional_per_asset_frac` (es. 0.5 -> equity/2) se la
frazione e' presente in config -> il cap cresce col capitale e non strozza il frazione e' presente in config -> il cap cresce col capitale e non strozza il
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
fidata; su fallback/offline si torna al cap fisso (protezione downside). fidata; su fallback/offline si torna al cap fisso (protezione downside).
@@ -136,6 +136,42 @@ def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
return s return s
def leva_tetto(cfg: dict | None = None) -> tuple[float, str]:
"""Leva lorda massima che il libro PUO' prendere. UNA formula, non tre (#28): prima era
ridichiarata in `usde.frazione_cuscino` (N_ASSET fisso, senza scala) e nella regola `leva` del
giornale (senza scala) -> a scala 1,25 il cuscino USDC risultava 0,30 dell'equity invece di
0,375, e la regola `leva` mostrava un tetto diverso da quello che il libro rispetta davvero.
leva = len(ASSETS) x min(WEIGHT, frac) x scala, al piu' LEVA_LORDA_MAX. -> (leva, come).
- frac = cfg["max_notional_per_asset_frac"] se presente, altrimenti WEIGHT. Il min(WEIGHT, frac)
viene dal fatto che `book_net_target` non supera mai weight*equity*(W_TP01+W_SKH) =
weight*equity (i due pesi sommano a 1): |raw| <= weight*equity, quindi |net_target| <=
scala*min(cap, weight*equity) -- un frac oltre WEIGHT alza il cap nominale ma non la leva
REALE che il libro puo' esprimere.
- scala = la scala DICHIARATA (`_scala(real_equity=1.0, cfg=cfg)`, gia' validata): un tetto
descrive quanto la config AUTORIZZA, non quanto un giro con equity illeggibile ha preso.
- Su `ScalaNonAutorizzata` (config fuori scaletta o fuori tetto) NON si solleva: un
sorvegliante (`cuscino_watch`) non deve morire proprio quando la config e' sbagliata
(verifica fable B5) -> si ritorna il tetto di CODICE, dichiarando l'anomalia in `come`.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
try:
scala = _scala(real_equity=1.0, cfg=cfg)
except ScalaNonAutorizzata as e:
breve = str(e).splitlines()[0]
breve = breve if len(breve) <= 80 else breve[:77] + "..."
return LEVA_LORDA_MAX, (f"config fuori scaletta/tetto ({breve}): "
f"tetto di codice LEVA_LORDA_MAX")
raw_frac = cfg.get("max_notional_per_asset_frac")
frac = float(raw_frac) if raw_frac is not None else WEIGHT
frac_eff = min(WEIGHT, frac)
leva = len(ASSETS) * frac_eff * scala
base = f"{len(ASSETS)} asset x frac {frac_eff} x scala {scala} = {leva:.2f}x"
if leva > LEVA_LORDA_MAX + 1e-12:
return LEVA_LORDA_MAX, f"{base}, troncato al tetto di codice {LEVA_LORDA_MAX}x"
return leva, base
def _read_watermark() -> float | None: def _read_watermark() -> float | None:
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile.""" """Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
@@ -269,6 +305,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) -> live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato, segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero).""" deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
sh = shadow_report(offline=offline, equity_override=equity_override) sh = shadow_report(offline=offline, equity_override=equity_override)
equity = sh["equity"] equity = sh["equity"]
# registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di # registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di
@@ -315,7 +352,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
net = book_net_target(tp_frac, sign, equity, cap, scala=scala) net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
cur = float(a_rec["position_usd"]) cur = float(a_rec["position_usd"])
mark = a_rec["mark"] mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=5.0) order = build_book_order(inst, net, cur, mark, min_usd=float(cfg.get("min_order_usd", 5.0)))
if order: if order:
orders.append(order) orders.append(order)
assets.append(dict( assets.append(dict(
+11 -8
View File
@@ -1,13 +1,16 @@
"""Accesso Deribit MAINNET in SOLA LETTURA (via Cerbero MCP) + costruttore ordini deterministico. """Accesso Deribit MAINNET (via Cerbero MCP) + costruttore ordini deterministico. La classe
`DeribitRead` qui e' in SOLA LETTURA (nessun metodo di trading, by design); l'esecuzione REALE la
estende in un modulo separato (`src.live.execution.DeribitTrader`) — non e' piu' un progetto
futuro (#35 A9): e' ARMATA e LIVE su Deribit mainnet dal 2026-06-23 (`execution_enabled` in
config/live.json), usata da `scripts/live/book_execute.py`.
Serve lo SHADOW MODE di TP01 (`scripts/live/live_trend.py`): legge prezzi / conto / posizioni REALI Da qui vengono prezzi / conto / posizioni REALI (token `.env.mainnet`): li usa sia il libro live
dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON (TP01+SKH01, via src.live.book + execution) sia lo shadow mode e i monitor di sorveglianza.
esiste alcun metodo di trading — by design: l'unica via per piazzare ordini sara' un modulo separato,
abilitato esplicitamente, dopo la validazione shadow + micro-test.
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale, Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet
e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount` reale. I contratti ESEGUITI sono i perp LINEARI USDC (`_USDC-PERPETUAL`, non inverse): `amount` in
in USD notional, step verificato su Deribit: BTC $10, ETH $1). base-coin (BTC/ETH), margine e P&L in USDC — vedi `_CONTRACT` sotto per step/tick/minimi per
strumento.
""" """
from __future__ import annotations from __future__ import annotations
+5 -4
View File
@@ -23,10 +23,11 @@ from dataclasses import dataclass
from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di # 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 # DEFAULT (in coin, sotto), usato SOLO quando nessuno ha chiamato set_open_cap() (il micro-test,
# micro-test, che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva # che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva set_open_cap()
# set_open_cap() dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop # dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop sopra il sizing
# sopra il sizing di TP01/book, non il sizing operativo. # di TP01/book, non il sizing operativo. (#35 A9: via il "~$630/$430 su conto ~$600" cablato —
# MAX_AMOUNT e' in coin, non in dollari, e non e' il tetto operativo da quando esiste set_open_cap.)
ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"}
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap() MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap()
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
+12 -14
View File
@@ -192,12 +192,10 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi) return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il # la leva lorda massima che il libro puo' tenere: UNA sola formula, in `book.leva_tetto`
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del # (P1: derivata da `book`, non ridichiarata — #28 ne aveva tre diverse fra qui,
# 01/09 entra qui da sola) # usde.frazione_cuscino e la regola `leva` sotto, e non tornavano)
scala = float(cfg.get("book_scale_k") or 1.0) leva_tetto = _book.leva_tetto(cfg)[0]
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
float(_book.LEVA_LORDA_MAX))
except Exception: except Exception:
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
px = {a: _px_1h(a) for a in ASSETS} if candidati else {} px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
@@ -567,17 +565,17 @@ def lettura(con, voce: dict) -> list[dict]:
# --- quanto capitale e' esposto ---------------------------------------------------------- # --- quanto capitale e' esposto ----------------------------------------------------------
lv = lb.get("leva_lorda") lv = lb.get("leva_lorda")
if lv is not None: if lv is not None:
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo # Il tetto si LEGGE da `book.leva_tetto`, non ridichiarato qui. Ridichiararlo e' lo schema
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura # che questo progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap,
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio # taratura venue_watch, indice del namespace) — e una quinta, con una formula diversa da
# bersaglio continua a passare il giorno che il bersaglio cambia. # quella di `movimenti_capitale` e di `usde.frazione_cuscino`, e' quella corretta da #28.
from src.live import book as _book # lazy: prende il tetto vivo, anche monkeypatchato
try: try:
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text()) cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
cap_frac = float(cfg["max_notional_per_asset_frac"]) tetto, come = _book.leva_tetto(cfg)
tetto = cap_frac * len(lb["asset"])
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x " dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
f"(`max_notional_per_asset_frac` {cap_frac:.2f} x {len(lb['asset'])} " f"({come}, da `config/live.json` e `book.leva_tetto`): "
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.") f"margine {tetto - lv:.2f}x.")
except Exception as e: except Exception as e:
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile " dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
f"({type(e).__name__} su config/live.json).", "attenzione") f"({type(e).__name__} su config/live.json).", "attenzione")
+46
View File
@@ -49,6 +49,9 @@ import numpy as np
MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate
MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura
GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO) GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO)
SOGLIA_ORARI_H = 6.0 # (#30) max_age_h <= questa soglia = "sorvegliante orario": i tre
# con max_age 3h (cuscino_watch, venue_watch, collect_chain) oggi,
# derivato dalla tabella e non da una lista di nomi cablata
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -244,3 +247,46 @@ def alerts_from(rows: list[dict]) -> list[str]:
msg += f" | METTE A RISCHIO il gate {r['gate']}" msg += f" | METTE A RISCHIO il gate {r['gate']}"
out.append(msg) out.append(msg)
return out return out
# ===========================================================================
# GIRO ORARIO (#30). Il giro giornaliero (cron_daily) resta com'e': report completo, avviso
# sempre. I sorveglianti a max_age <= 6h (oggi cuscino_watch, venue_watch, collect_chain) si
# scoprono fermi fino a 24h dopo se nessuno li guarda fuori da quel giro: qui si guardano ogni
# ora, ma si notifica solo cio' che e' NUOVO rispetto all'ultimo avviso (niente Telegram a
# raffica sullo stesso guasto fermo). Tutto PURO: il marcatore lo legge/scrive lo script.
# ===========================================================================
def monitors_orari(monitors: tuple[MonitorSpec, ...] = MONITORS,
soglia_h: float = SOGLIA_ORARI_H) -> tuple[MonitorSpec, ...]:
"""I monitor con cadenza di controllo ORARIA: max_age_h <= soglia_h. Derivato dalla tabella
`MONITORS`, non da una lista di nomi cablata: aggiungere un monitor orario lo include qui
senza toccare questa funzione."""
return tuple(m for m in monitors if m.max_age_h <= soglia_h)
def chiave_alert(row: dict) -> str:
"""Chiave stabile di un alert per il marcatore 'gia' detto': nome + stato, NON il testo
(`why` contiene l'eta' dell'ultima barra, che cambia a ogni giro anche se il guasto e' lo
stesso)."""
return f"{row['name']}:{row['status']}"
def alert_nuovi(rows: list[dict], detti: set[str]) -> list[dict]:
"""Le righe-alert (non OK, non NUOVO — vedi `alerts_from`) la cui chiave NON e' nel
marcatore `detti`: quelle davvero nuove, le sole da notificare."""
return [r for r in rows if r["status"] not in ("OK", "NUOVO") and chiave_alert(r) not in detti]
def marcatore_aggiornato(rows: list[dict], detti: set[str]) -> set[str]:
"""Il marcatore dopo questo giro: le chiavi dei monitor ancora in alert restano/si aggiungono,
quelle dei monitor tornati OK si TOLGONO — cosi' un nuovo guasto sullo stesso monitor riparte
con un nuovo avviso invece di restare 'gia' detto' per sempre. Non scrive nulla: lo fa lo
script, e solo se la notifica e' partita (stessa regola di fee_watch #29)."""
out = set(detti)
for r in rows:
chiavi_monitor = {k for k in out if k.startswith(f"{r['name']}:")}
if r["status"] == "OK":
out -= chiavi_monitor
elif r["status"] != "NUOVO":
out.add(chiave_alert(r))
return out
+40 -8
View File
@@ -39,8 +39,16 @@ FINESTRA_GIORNALE_G = 7 # pagine del giornale lette
FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale) FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
FINESTRA_COMMIT_G = 14 # commit letti FINESTRA_COMMIT_G = 14 # commit letti
CARTELLA = Path("docs") / "revisioni" CARTELLA = Path("docs") / "revisioni"
MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo MAX_CHARS_VOCE = 90_000 # il resto delle voci e' molto piu' piccolo
# #37 C12. CLAUDE.md e' arrivato a 89.590 caratteri (99,5% di MAX_CHARS_VOCE, 27/09): al primo
# paragrafo aggiunto si sarebbe troncato. Tetto SUO, piu' largo — il vincolo vero resta
# MAX_CHARS_TOTALE, applicato A PARTE in `materiale()` a prescindere dal tetto di ogni voce: la
# somma dei tetti per-voce (che supera 400k) non e' il limite, lo e' il secondo giro di `_tronca`.
MAX_CHARS_CLAUDE_MD = 150_000
MAX_CHARS_TOTALE = 400_000 MAX_CHARS_TOTALE = 400_000
SOGLIA_AVVISO_TETTO = 0.95 # avviso quando una voce supera questa frazione del SUO tetto
_MARCA_VICINO_TETTO = "[… vicino al tetto:" # marcatore condiviso: materiale() lo scrive, i
# consumatori (prompt, rapporto) lo rileggono
MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito
LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata
TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre
@@ -89,6 +97,12 @@ def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
def _cap_voce(nome: str) -> int:
"""Tetto PER-VOCE: CLAUDE.md ne ha uno suo, piu' largo (#37 C12). Il totale
(MAX_CHARS_TOTALE) resta un vincolo A PARTE, applicato in `materiale()` a valle di questo."""
return MAX_CHARS_CLAUDE_MD if nome.startswith("CLAUDE.md") else MAX_CHARS_VOCE
def _cmd(args: list[str], cwd: Path) -> str: def _cmd(args: list[str], cwd: Path) -> str:
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo» """Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
non e' «va tutto bene»).""" non e' «va tutto bene»)."""
@@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
out, tot = [], 0 out, tot = [], 0
for nome, testo in voci: for nome, testo in voci:
t, tr = _tronca(testo) cap = _cap_voce(nome)
# #37 C12: avviso PRIMA di troncare, quando la voce e' vicina al SUO tetto (non ancora al
# tetto totale, sotto): la nota entra nel testo, cosi' arriva sia al prompt del revisore
# sia — via _MARCA_VICINO_TETTO — al rapporto, come [TRONCATO] oggi. Il margine
# (`+ len(nota) <= cap`) e' voluto: la nota stessa non deve MAI far scattare il
# troncamento che sta segnalando in anticipo.
if len(testo) >= SOGLIA_AVVISO_TETTO * cap:
nota = (f"\n{_MARCA_VICINO_TETTO} {len(testo):,} di {cap:,} caratteri "
f"({100 * len(testo) / cap:.0f}%)]")
if len(testo) + len(nota) <= cap:
testo = testo + nota
t, tr = _tronca(testo, cap)
if tot + len(t) > MAX_CHARS_TOTALE: if tot + len(t) > MAX_CHARS_TOTALE:
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
tot += len(t) tot += len(t)
@@ -186,7 +211,8 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str: def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str:
parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"] parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"]
for nome, testo, tr in voci: for nome, testo, tr in voci:
parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n") tag = " [TRONCATO]" if tr else (" [VICINO AL TETTO]" if _MARCA_VICINO_TETTO in testo else "")
parti.append(f"\n=== {nome}{tag} ===\n{testo}\n")
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.") parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
return "\n".join(parti) return "\n".join(parti)
@@ -236,7 +262,8 @@ def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str
"- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file " "- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
"scritto e' questo", "", "scritto e' questo", "",
"| voce | caratteri | troncata |", "|---|---|---|"] "| voce | caratteri | troncata |", "|---|---|---|"]
coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci] coda += [f"| {n} | {len(t):,} | "
f"{'si' if tr else ('quasi' if _MARCA_VICINO_TETTO in t else '')} |" for n, t, tr in voci]
return "\n".join(testa + corpo + coda) + "\n" return "\n".join(testa + corpo + coda) + "\n"
@@ -258,10 +285,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n" return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>") f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)" sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
corpo = html.escape(sintesi, quote=False)
tagliato = "" tagliato = ""
if len(corpo) > LIMITE_TELEGRAM: # (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]" # un'entita' a meta' (es. "&am") e Telegram rifiuta l'intero messaggio ("can't parse
# entities") invece di accorciarlo. Il marcatore di taglio si aggiunge dopo, fuori dall'escape
# (e' testo nostro, senza `&`/`<`: non ha bisogno di escape).
if len(sintesi) > LIMITE_TELEGRAM:
sintesi, tagliato = sintesi[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
corpo = html.escape(sintesi, quote=False)
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n" return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>") f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
@@ -312,7 +343,8 @@ def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, " print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else "")) f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
for n, t, tr in voci: for n, t, tr in voci:
print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}") tag = " [troncata]" if tr else (" [vicino al tetto]" if _MARCA_VICINO_TETTO in t else "")
print(f" {len(t):>8,} {n}{tag}")
if secco: if secco:
return esito return esito
+107 -9
View File
@@ -2,20 +2,34 @@
PERCHE' ESISTE PERCHE' ESISTE
-------------- --------------
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della `logs/cron_book.log` e' **gitignored, non nel backup e ruotabile**, ed e' la fonte che `sync()`
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill: usa per popolare il DB: una rotazione lo cancellerebbe senza che nessuno se ne accorga.
19 righe su 19 a `00:00:00`. L'ora vera sta solo in `logs/cron_book.log` — che e' **gitignored,
non nel backup e ruotabile**. Quindi oggi la cronologia reale del libro live vive in un file
che una rotazione cancella, e nessuno se ne accorgerebbe.
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
il perimetro gia' coperto dal backup rotativo della VPS. il perimetro gia' coperto dal backup rotativo della VPS.
⚠️ CORREZIONE (#37 C11, #35 A9). Fino al 2026-08-23 `data/live/book_executions.jsonl` scriveva in
`ts_utc` la data della **barra di segnale** (`pd.Timestamp(r['last_data'])`), non l'ora del fill:
19 righe su 19 a `00:00:00`. Dal 2026-08-24 01:07Z `book_execute.py` (`_registra_fill`) scrive
l'ora VERA del fill (`datetime.now(timezone.utc)` al momento dell'ordine, non piu' la barra),
UNA per fill — con il prezzo a piena precisione (non arrotondato come nella stampa del log) e il
campo `pos_after` (letto dal reconcile subito dopo il rebalance, #39 — None se quella lettura
solleva, o se il passo e' un `RebalanceParziale` a meta'). E' quindi, dal 24/08 in poi, la fonte
con l'ora PIU' precisa per-fill. Dal 2026-09-27 (#40) `sync()` la usa anche per ARRICCHIRE il
DB: `arricchisci_da_jsonl()` gira dopo l'inserimento e prima dei round-trip, e le righe dal 24/08
finiscono con `ts_utc`/`price` dal fill vero e `ts_source='jsonl'` invece che `'cron_log'` (e
`pos_after` quando il jsonl lo porta) — SOLO se l'abbinamento e' univoco, altrimenti resta com'e'
e conta come ambiguo.
FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola
---------------------------------------------------------------------- ----------------------------------------------------------------------
1. `logs/cron_book.log` -> ORA VERA del giro + contesto (equity, tp_frac, skh, target, posizione) 1. `logs/cron_book.log` -> l'ora di INIZIO GIRO (una per run, condivisa da tutti i fill di quel
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. run, non per-fill) + contesto (equity, tp_frac, skh, target,
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora. posizione) e la riga di fill
`-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. FONTE usata da `sync()`.
2. `data/live/book_executions.jsonl` -> gli stessi fill, con l'ora VERA per-fill dal 2026-08-24
01:07Z (prima: la data della barra di segnale, 00:00:00), prezzo a
piena precisione e `pos_after`. Entra SOLO in `reconcile()`.
3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms, 3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms,
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH. **ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
@@ -32,7 +46,7 @@ import json
import re import re
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -42,6 +56,10 @@ EXEC_JSONL = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20). # Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
ARMING = "2026-06-20" ARMING = "2026-06-20"
# Da qui in poi book_executions.jsonl porta l'ora VERA del fill in `ts_utc` (fix #35 A9, docstring
# sopra): soglia per il controllo di conteggio jsonl-vs-DB in scripts/live/trades_db.py:sync()
# (#37 C11). Prima di questo istante il campo e' la barra di segnale, non comparabile.
CUTOFF_TS_VERO = "2026-08-24T01:07:00+00:00"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS fills ( CREATE TABLE IF NOT EXISTS fills (
@@ -333,6 +351,86 @@ def upsert_fills(con: sqlite3.Connection, fills: list[Fill]) -> int:
return n return n
def _ts_utc(s) -> datetime | None:
"""Parsa un timestamp ISO in un datetime AWARE utc. None se illeggibile.
Le 19 righe scritte prima del fix #35 A9 (barra di segnale, `pd.Timestamp` in stampa: es.
"2026-07-08 00:00:00", spazio invece di 'T', nessun offset) sono NAIVE — senza questa
normalizzazione, confrontarle con un ts_utc del DB (sempre aware, +00:00) solleva
`TypeError: can't compare offset-naive and offset-aware datetimes` (misurato sui dati veri,
27/09/2026). Sono tutte precedenti a CUTOFF_TS_VERO: qui si limita a non farle esplodere,
non a farle abbinare — restano fuori dalla finestra di qualunque candidato dal 24/08."""
if not s:
return None
try:
ts = datetime.fromisoformat(str(s))
except ValueError:
return None
return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts
def arricchisci_da_jsonl(con: sqlite3.Connection, righe_jsonl: list[dict]) -> tuple[int, int]:
"""Arricchisce le righe `ts_source='cron_log'` con l'ora vera, il prezzo e `pos_after` letti
da `book_executions.jsonl` (#40, TradingBook#11: lo sviluppo lasciato non fatto dal
commento sopra). NON tocca `fill_id`: e' la chiave della riga, calcolata all'INSERT da
ts_utc/asset/side/qty/price ORIGINALI (cron_log) — qui si fa un UPDATE per `fill_id`, che
cambia solo le colonne, mai la chiave con cui la riga e' indicizzata.
Candidate: `ts_source='cron_log'` e `ts_utc >= CUTOFF_TS_VERO` (prima di quella soglia il
jsonl portava la barra di segnale, non l'ora vera — non ha una controparte comparabile).
Abbinamento a una riga jsonl: stesso asset, stesso side, |qty - filled| < 1e-9, e ts jsonl in
[ts_utc DB, ts_utc DB + 15 minuti] (il DB ha l'ora di INIZIO GIRO, il jsonl l'ora del fill,
che viene dopo). Esattamente UN candidato -> UPDATE; zero o piu' di uno -> non si tocca (non
si indovina quale, o se ce n'e' uno) e conta come ambiguo. Un record jsonl assegnato non si
riusa: due fill identici nello stesso giro non finiscono abbinati alla stessa riga.
Idempotente: una riga gia' `ts_source='jsonl'` non e' piu' candidata al giro successivo.
Ritorna (righe_aggiornate, righe_ambigue)."""
candidati = con.execute(
"SELECT fill_id, ts_utc, asset, side, qty FROM fills "
"WHERE ts_source='cron_log' AND ts_utc >= ? ORDER BY ts_utc, asset",
(CUTOFF_TS_VERO,)).fetchall()
usati: set[int] = set() # indici in righe_jsonl gia' assegnati a un fill_id
aggiornate = 0
ambigue = 0
for riga in candidati:
ts_db = _ts_utc(riga["ts_utc"])
if ts_db is None:
ambigue += 1 # ts illeggibile: non e' un abbinamento, e' un dato rotto
continue
fine = ts_db + timedelta(minutes=15)
trovati = []
for i, r in enumerate(righe_jsonl):
if i in usati:
continue
if r.get("asset") != riga["asset"] or r.get("side") != riga["side"]:
continue
filled = r.get("filled")
if filled is None or abs(float(filled) - riga["qty"]) >= 1e-9:
continue
ts_j = _ts_utc(r.get("ts_utc"))
if ts_j is None or not (ts_db <= ts_j <= fine):
continue
trovati.append(i)
if len(trovati) != 1:
ambigue += 1
continue
i = trovati[0]
r = righe_jsonl[i]
usati.add(i)
pos_after = r.get("pos_after")
if pos_after is None:
con.execute("UPDATE fills SET ts_utc=?, ts_source='jsonl', price=? WHERE fill_id=?",
(r["ts_utc"], r["price"], riga["fill_id"]))
else:
con.execute(
"UPDATE fills SET ts_utc=?, ts_source='jsonl', price=?, pos_after=? WHERE fill_id=?",
(r["ts_utc"], r["price"], pos_after, riga["fill_id"]))
aggiornate += 1
con.commit()
return aggiornate, ambigue
def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int: def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int:
n = 0 n = 0
for ts, v, src in punti: for ts, v, src in punti:
+6 -6
View File
@@ -104,12 +104,12 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO # il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
# (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e' # (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e'
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere: # il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
# n_asset x frac x disaster_sl_pct x equity_totale # book.leva_tetto() x disaster_sl_pct x equity_totale
# tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la # tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui, # formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
# e usde_convert pure — due lettori, una formula. # e usde_convert pure — due lettori, una formula. ⚠️ Fino al #28 la leva lorda era RIDICHIARATA
# qui (N_ASSET fisso x WEIGHT, senza scala): a scala 1,25 dava 0,30 invece di 0,375. Ora la stessa
N_ASSET = 2 # `book.leva_tetto` che usa anche il giornale (src/live/journal.py).
def frazione_cuscino() -> tuple[float, str]: def frazione_cuscino() -> tuple[float, str]:
@@ -117,8 +117,8 @@ def frazione_cuscino() -> tuple[float, str]:
cfg = json.loads(CFG_PATH.read_text()) cfg = json.loads(CFG_PATH.read_text())
sl = float(cfg["disaster_sl_pct"]) sl = float(cfg["disaster_sl_pct"])
from src.live import book as B # lazy: book importa shadow, che importa questo modulo from src.live import book as B # lazy: book importa shadow, che importa questo modulo
frac = float(B.WEIGHT) leva, come_leva = B.leva_tetto(cfg)
return N_ASSET * frac * sl, f"{N_ASSET} asset x frac {frac} x SL {sl:.0%}" return leva * sl, f"{come_leva} x SL {sl:.0%}"
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]: def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
+33 -22
View File
@@ -28,19 +28,21 @@ col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per u
se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5). se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5).
La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente. La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente.
⚠️ **LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e' ⚠️ **IL RAMO CHE DECLASSAVA E' MORTO, RIMOSSO DA `diagnose()` (#35 B11, 27/09/2026).**
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA `in_release_window()` diceva se l'orologio cadeva nello slot di release Deribit (martedi'
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che 09:00-09:30 UTC); `diagnose()` la usava per declassare la GRAVITA' di una manutenzione gia'
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un riconosciuta dalla SONDA (REGOLA P9 — *un allarme massimo speso per un evento atteso e' un
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio allarme che non verra' letto il giorno che e' vero*). Ma l'UNICO chiamante di `diagnose()` e'
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di `book_execute.py`, in cron al minuto **:47** di ogni ora (dal 25/08): la finestra 09:00-09:30 non
`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero' contiene mai :47, quindi quel ramo non poteva MAI diventare vero — si leggeva come una
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario, declassazione attiva e non lo e' mai stata. Il campo `atteso` RESTA sul verdetto
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare. (`book_execute.py` lo scrive nel dict del report: cambiare il formato per una pulizia e' un costo
che non vale la pena) ma e' hardcoded a False in `diagnose()`. `in_release_window()` e le
REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il costanti `RELEASE_*` restano invece nel modulo: le usa `test_book_cadenza.py`/`test_revisione.py`
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra per verificare che i cron di book/review evitino lo stesso slot — uno scopo indipendente da
di meno. L'episodio finisce nel log e nella notifica comunque. `diagnose()` e ancora valido. Per declassare di nuovo un evento annunciato in `diagnose()` la
strada e' legarlo alla DURATA dell'annuncio (`venue_news`, se e quando esiste), non all'orologio
di un chiamante che non e' mai sincrono con lo slot.
""" """
from __future__ import annotations from __future__ import annotations
@@ -53,7 +55,7 @@ import requests
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
# --- verdetti ----------------------------------------------------------------------------- # --- verdetti -----------------------------------------------------------------------------
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051
V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione
V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile) V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5) V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
@@ -70,8 +72,12 @@ PROBE_TIMEOUT = 8.0
# 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes"). # 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes").
# ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica # ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica
# dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta # dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta
# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola — # lo slot, questa costante mente in silenzio.
# decide la sonda, e la finestra al massimo declassa. # ⚠️ #35 B11 (27/09/2026): questa finestra NON declassa piu' niente in `diagnose()` (vedi sotto):
# l'unico chiamante gira in cron al minuto :47, che non ci cade mai dentro. La funzione e le
# costanti restano perche' `test_book_cadenza.py`/`test_revisione.py` le usano per un controllo
# diverso e ancora valido: che i cron di `book`/`review` cadano FUORI da questo slot (P1, la
# stessa finestra, non una seconda dichiarata a mano).
RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi' RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
RELEASE_START_MIN = 9 * 60 # 09:00 UTC RELEASE_START_MIN = 9 * 60 # 09:00 UTC
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia. RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
@@ -79,7 +85,8 @@ RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione to
@dataclass(frozen=True) @dataclass(frozen=True)
class Diagnosi: class Diagnosi:
"""Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9).""" """Il verdetto (*cosa*) e la spiegazione (*perche'*). `atteso` resta nel formato ma e' sempre
False (#35 B11): l'unico chiamante non cade mai nella finestra che l'avrebbe declassato."""
verdetto: str verdetto: str
perche: str perche: str
atteso: bool atteso: bool
@@ -87,7 +94,8 @@ class Diagnosi:
@property @property
def gravita(self) -> str: def gravita(self) -> str:
"""Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9).""" """Emoji del titolo di notifica. `atteso` e' sempre False (#35 B11): ramo morto lasciato
per compatibilita' di formato, non per una declassazione che avviene ancora."""
if self.atteso: if self.atteso:
return "ℹ️" return "ℹ️"
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️") return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
@@ -106,8 +114,9 @@ class Diagnosi:
def in_release_window(now: datetime | None = None) -> bool: def in_release_window(now: datetime | None = None) -> bool:
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)? """L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo. PURA e testabile. Non declassa piu' niente in `diagnose()` (#35 B11): resta per chi verifica
""" che un cron (book, review) sia schedulato fuori da questo slot (P1: la finestra si deriva da
qui, non si riscrive una seconda volta nel test)."""
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
now = now.astimezone(timezone.utc) now = now.astimezone(timezone.utc)
if now.weekday() != RELEASE_WEEKDAY: if now.weekday() != RELEASE_WEEKDAY:
@@ -136,7 +145,6 @@ def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]:
def diagnose(errori_osservati: list[str] | None = None, def diagnose(errori_osservati: list[str] | None = None,
now: datetime | None = None,
sonda=None) -> Diagnosi: sonda=None) -> Diagnosi:
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete. """Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
@@ -169,7 +177,10 @@ def diagnose(errori_osservati: list[str] | None = None,
verdetto = V_VENUE_GIU verdetto = V_VENUE_GIU
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione" perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
atteso = verdetto == V_MANUTENZIONE and in_release_window(now) # #35 B11: qui c'era `verdetto == V_MANUTENZIONE and in_release_window(now)`. L'unico
# chiamante (book_execute.py, cron :47) non cade mai nella finestra 09:00-09:30 di martedi':
# il ramo non poteva mai diventare vero. Il campo resta (formato del report), sempre False.
atteso = False
prova = "; ".join([*errori[:3], grezzo])[:500] prova = "; ".join([*errori[:3], grezzo])[:500]
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova) return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
+9 -3
View File
@@ -400,8 +400,14 @@ def deribit_book_sleeves() -> list[Sleeve]:
e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step). e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step).
Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento
periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py). periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py).
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).""" TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).
I pesi si DERIVANO da src.live.book (P1): due letterali qui erano una seconda dichiarazione,
libera di divergere dal book vero (#35 B12). Import locale: book importa questo modulo
(_skyhook_positions) a livello di modulo -> un import in testa qui creerebbe un ciclo (come
fa usde.py con book)."""
from src.live import book as B # lazy: vedi nota sul ciclo qui sopra
return [ return [
tp01_sleeve(weight=0.75), tp01_sleeve(weight=B.W_TP01),
skyhook_sleeve(weight=0.25), skyhook_sleeve(weight=B.W_SKH),
] ]
+48
View File
@@ -285,6 +285,39 @@ def test_la_testata_porta_i_numeri_che_si_leggono_dalla_notifica():
t = A.testata(_voce_piena()) t = A.testata(_voce_piena())
assert "$637.36" in t and "+2.36" in t and "+39.30" in t assert "$637.36" in t and "+2.36" in t and "+39.30" in t
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: non e' "oggi", e' "nel giorno".
assert "nel giorno" in t and "oggi" not in t
def test_testata_con_movimento_mostra_trading_giorno_non_il_grezzo():
"""(#35 C8) Un versamento di $1.399 non deve leggersi come P&L: con mov_giorno != 0 e
trading_giorno calcolabile, la cifra mostrata e' trading_giorno, non delta_equity."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=2.36)
t = A.testata(voce)
assert "+2.36 nel giorno" in t
assert "di cui +1399.00 movimenti" in t
assert "+1401.36" not in t # il grezzo non compare piu' in testata
def test_testata_con_movimento_ma_trading_giorno_none_resta_sul_grezzo():
"""(#35 C8) Se trading_giorno non e' calcolabile (None) si resta su delta_equity, ma con
l'etichetta nuova, non "oggi"."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=None)
t = A.testata(voce)
assert "+1401.36 nel giorno" in t
assert "di cui" not in t
def test_testata_senza_movimento_mostra_il_grezzo_con_etichetta_nuova():
"""(#35 C8) mov_giorno assente o 0: nessun movimento da scorporare, stesso trattamento di
journal.scorporo (bool())."""
voce = _voce_piena()
voce["pnl"] = dict(voce["pnl"], mov_giorno=0.0, trading_giorno=2.36)
t = A.testata(voce)
assert "+2.36 nel giorno" in t
assert "di cui" not in t
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto(): def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
@@ -312,6 +345,21 @@ def test_il_taglio_a_4096_si_DICHIARA():
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
def test_taglio_a_cavallo_di_entita_e_tag_resta_HTML_valido():
"""(#35 C13) Il taglio e' sul GREZZO, prima di _b/html.escape e prima di **-><b>: un '&' o
un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata
(es. '&am') ne' un <b> senza chiusura -- Telegram rifiuta l'INTERO messaggio altrimenti
("can't parse entities"). Il pattern si ripete per tutta la lunghezza, cosi' il punto di
taglio (qualunque sia) cade sempre dentro o a cavallo di un '&' o di '**...**'."""
pattern = "parola & altra parola **in grassetto** ancora testo "
analisi = pattern * 200 # ben oltre qualunque budget qui
m = A.per_telegram(_voce_piena(), analisi, "ok", [])
assert "tagliato" in m
assert re.search(r"&(?!amp;|lt;|gt;)", m) is None # nessuna entita' spezzata o '&' nudo
assert m.count("<b>") == m.count("</b>") # ogni <b> aperto e' chiuso
assert m.count("<i>") == m.count("</i>")
def test_un_messaggio_normale_non_viene_toccato(): def test_un_messaggio_normale_non_viene_toccato():
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", []) m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
assert "tagliato" not in m and m.endswith("Analisi breve.") assert "tagliato" not in m and m.endswith("Analisi breve.")
+21
View File
@@ -150,6 +150,27 @@ def test_weights_match_backtest_sleeves():
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12 assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
def test_deribit_book_sleeves_deriva_i_pesi_da_book_non_li_ridichiara():
"""#35 B12 — P1: stesso pattern di test_r0909_libro_nei_crolli.test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia.
`test_weights_match_backtest_sleeves` sopra passerebbe anche con due letterali per coincidenza
di valore (0.75/0.25 riscritti a mano): qui si verifica l'IDENTITA' con B.W_TP01/B.W_SKH e
che il letterale non sia piu' nella fonte di `deribit_book_sleeves`."""
import inspect
import re
import src.live.book as B
from src.portfolio.sleeves import deribit_book_sleeves
pesi = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
assert pesi["TP01"] is B.W_TP01 or pesi["TP01"] == B.W_TP01
assert pesi["SKH01"] is B.W_SKH or pesi["SKH01"] == B.W_SKH
src = inspect.getsource(deribit_book_sleeves)
assert "from src.live import book" in src, "i pesi devono derivare da src.live.book (P1)"
assert not re.search(r"weight\s*=\s*0\.75", src), "peso TP01 ancora cablato"
assert not re.search(r"weight\s*=\s*0\.25", src), "peso SKH01 ancora cablato"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Costruzione ordine: side, reduce_only, flip, close, soglia minima. # Costruzione ordine: side, reduce_only, flip, close, soglia minima.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+88
View File
@@ -332,6 +332,94 @@ def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monke
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None
# ---------------------------------------------------------------------------
# #39 — regressione di #23: pos_after finiva sempre None nel jsonl, anche quando il reconcile
# leggeva la posizione senza problemi (la lettura era rimasta un side-effect informativo, il suo
# risultato non arrivava piu' a `_registra_fill`).
# ---------------------------------------------------------------------------
def test_rebalance_ok_scrive_pos_after_dal_reconcile(tmp_path, monkeypatch, capsys):
"""book_execute (#39): rebalance_signed e position_usd vanno a segno entrambi -> il record
di log_event ha pos_after = il valore letto dal reconcile, non None."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderOk:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill] if inst.startswith("BTC") else []
def position_usd(self, inst):
return 500.0
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run()
out = capsys.readouterr().out
assert "reconcile: pos $500" in out
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] == 500.0
def test_position_usd_che_solleva_non_impedisce_il_fill_pos_after_none(tmp_path, monkeypatch, capsys):
"""book_execute (#39): stesso scenario di sopra ma il reconcile solleva -> il fill si
registra comunque (vincolo #23), con pos_after None."""
mod = carica_script("scripts/live/book_execute.py")
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
class _TraderReconcileBoom:
def set_open_cap(self, inst, cap_usd, mark):
pass
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
return [fill] if inst.startswith("BTC") else []
def position_usd(self, inst):
raise RuntimeError("api 500")
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderReconcileBoom())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=True, min_order_usd=5.0,
disaster_sl_pct=0.30, max_data_age_days=2.0,
skh_feed_max_age_min=30.0))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
mod._run() # non deve sollevare: il fill non e' perso
out = capsys.readouterr().out
assert "reconcile: pos illeggibile (RuntimeError)" in out
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
assert len(righe) == 1
assert righe[0]["asset"] == "BTC" and righe[0]["filled"] == 0.003
assert righe[0]["pos_after"] is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Il formato delle righe stampate non e' cambiato: tradesdb.parse_cron_log deve continuare a # 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). # leggerlo, sia in un giro normale sia in un giro con RebalanceParziale (#23).
+60 -1
View File
@@ -155,7 +155,15 @@ def test_un_tentativo_ogni_sei_ore():
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0] assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
def _finto_lettore_quota(valori):
"""Ritorna i valori dati in ordine, uno per chiamata (per monkeypatch di _leggi_quota_usde)."""
it = iter(valori)
return lambda: next(it)
def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
# rc==0: "ok" a prescindere dalla quota (lo script vero ha gia' le sue guardie, P15) — la
# quota qui non si muove, per mostrare che il caso "ok" non dipende dalla rilettura.
chiamate = [] chiamate = []
class R: class R:
@@ -166,10 +174,61 @@ def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
return R() return R()
monkeypatch.setattr(W.subprocess, "run", finto_run) monkeypatch.setattr(W.subprocess, "run", finto_run)
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.67, 0.67]))
e = W.riconverti(0.67) e = W.riconverti(0.67)
assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py") assert e["ok"] and e["esito"] == "ok" and chiamate and chiamate[0][1].endswith("usde_convert.py")
assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0] assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
assert e["coda"][-1].startswith(" ESITO") assert e["coda"][-1].startswith(" ESITO")
assert e["quota_prima"] == 0.67 and e["quota_dopo"] == 0.67
def test_riconverti_rc_diverso_da_zero_con_quota_mossa_e_parziale(monkeypatch):
# #35 B10: rc!=0 (es. ordine parzialmente fillato poi rifiutato) ma la quota si e' mossa
# per davvero -> "parziale", non "fallita": la vendita/acquisto e' avvenuto, solo non finito.
class R:
returncode, stdout, stderr = 1, "", "book illeggibile a meta' piano\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.70]))
e = W.riconverti(0.64)
assert e["esito"] == "parziale" and e["ok"] is False
assert e["quota_prima"] == 0.75 and e["quota_dopo"] == 0.70
def test_riconverti_rc_diverso_da_zero_con_quota_ferma_e_fallita(monkeypatch):
class R:
returncode, stdout, stderr = 1, "", "prezzo fuori banda: niente ordini\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.751]))
e = W.riconverti(0.64)
assert e["esito"] == "fallita" and e["ok"] is False
def test_riconverti_timeout_con_quota_mossa_e_parziale(monkeypatch):
# #35 B10: il subprocess viene ucciso a TIMEOUT_CONVERT_S ma alcuni ordini erano gia' fillati
# prima del kill -> oggi (ok=rc==0) diventava "FALLITA", corretto e' "parziale".
def finto_run(cmd, **kw):
raise W.subprocess.TimeoutExpired(cmd, kw.get("timeout"))
monkeypatch.setattr(W.subprocess, "run", finto_run)
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.80, 0.72]))
e = W.riconverti(0.64)
assert e["rc"] is None and e["esito"] == "parziale" and e["ok"] is False
assert e["coda"][0].startswith("timeout dopo")
def test_riconverti_rilettura_dopo_fallita_e_fallita_esplicita(monkeypatch):
# se non si riesce a rileggere la quota dopo, non si puo' dire "parziale": si dichiara
# l'incertezza invece di dedurre un movimento zero che non si e' verificato.
class R:
returncode, stdout, stderr = 1, "", "errore\n"
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, None]))
e = W.riconverti(0.64)
assert e["esito"] == "fallita (quota dopo non leggibile)" and e["ok"] is False
assert e["quota_dopo"] is None
def test_il_monitor_e_registrato_in_monitor_health(): def test_il_monitor_e_registrato_in_monitor_health():
+97 -1
View File
@@ -204,7 +204,10 @@ def test_invio_riuscito_salva_lo_stato(monkeypatch):
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True) salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
FW.main() FW.main()
assert len(inviato) == 1 assert len(inviato) == 1
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"] # (#37 B9) lo stato salvato porta anche il marcatore "divergenza_detta" (qui False: la
# fixture non ha divergenze), sibling delle fee per-strumento, non piu' solo `current`.
assert len(salvato) == 1
assert salvato[0] == {**_RIS_AZIONE["current"], "divergenza_detta": False}
def test_nessun_invio_dovuto_salva_comunque(monkeypatch): def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
@@ -213,3 +216,96 @@ def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
FW.main() FW.main()
assert inviato == [], "senza changes e livello OK non deve tentare l'invio" assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
assert len(salvato) == 1 assert len(salvato) == 1
# ===========================================================================
# issue #37 (B9) — realized_fee_bps() SEMPRE calcolato (non solo nel blocco di stampa) e
# divergenza dal tier pubblico notificata UNA volta per episodio.
# ===========================================================================
def test_run_calcola_sempre_la_fee_realizzata(monkeypatch):
"""Prima del #37 la fee realizzata girava solo dentro `if not quiet or interessante`: in
cron (--quiet) non girava mai. Ora e' `run()` a calcolarla, sempre."""
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
ins = FW.INSTRUMENTS[0]
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 7.5})
r = FW.run()
assert r["realized"] == {ins: 7.5}
assert len(r["divergenze"]) == 1 and ins in r["divergenze"][0]
assert r["divergenza_gia_detta"] is False
def test_run_nessun_trade_e_None_non_zero(monkeypatch):
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {})
r = FW.run()
assert r["realized"] is None
assert r["divergenze"] == []
def test_run_sotto_soglia_non_e_divergenza(monkeypatch):
monkeypatch.setattr(FW, "fetch_fees",
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
monkeypatch.setattr(FW, "load_state", lambda: {})
ins = FW.INSTRUMENTS[0]
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 5.3}) # +0.3 < 1.0
r = FW.run()
assert r["divergenze"] == []
_RIS_DIVERGE = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
realized={"BTC_USDC-PERPETUAL": 7.5},
divergenze=["BTC_USDC-PERPETUAL: realizzata 7.50 bps vs tier pubblico 5.00 (+2.50)"],
divergenza_gia_detta=False,
)
_RIS_DIVERGE_RIPETUTA = dict(_RIS_DIVERGE, divergenza_gia_detta=True)
_RIS_DIVERGE_RIENTRATA = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
realized={"BTC_USDC-PERPETUAL": 5.3}, divergenze=[], divergenza_gia_detta=True,
)
def _prepara_b9(monkeypatch, risultato, esito_invio):
"""Come `_prepara`, ma senza `changes`/livello AZIONE: isola il notify di B9 da quello di
B8 (qui non richiesto, dato che tutte le fixture hanno level='OK' e changes=[])."""
salvato, inviato = [], []
monkeypatch.setattr(FW, "run", lambda: risultato)
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
return salvato, inviato
def test_divergenza_prima_volta_notifica_e_marca(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=True)
FW.main()
assert len(inviato) == 1
assert salvato[-1]["divergenza_detta"] is True
def test_divergenza_stesso_episodio_non_ripete(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIPETUTA, esito_invio=True)
FW.main()
assert inviato == [], "gia' detta in questo episodio: nessun secondo invio"
assert salvato[-1]["divergenza_detta"] is True
def test_divergenza_notify_fallita_non_marca(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=False)
FW.main()
assert len(inviato) == 1
assert salvato[-1]["divergenza_detta"] is False, \
"invio fallito -> marcatore non scritto, il giro dopo ritenta"
def test_divergenza_rientrata_azzera_il_marcatore(monkeypatch):
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIENTRATA, esito_invio=False)
FW.main()
assert inviato == [], "sotto soglia: nessun invio da tentare"
assert salvato[-1]["divergenza_detta"] is False
+140
View File
@@ -0,0 +1,140 @@
"""book.leva_tetto — la leva lorda massima del libro, in UNA sola formula (#28).
Prima della correzione la leva lorda tornava con tre formule diverse: `usde.frazione_cuscino`
(N_ASSET fisso, senza scala), la regola `leva` del giornale (senza scala) e
`journal.movimenti_capitale` (con scala, ma ridichiarata). A scala 1,25 il cuscino USDC
risultava 0,30 dell'equity invece di 0,375 — il caso che T2 riproduce.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import book as B # noqa: E402
from src.live import journal as J # noqa: E402
from src.live import tradesdb as T # noqa: E402
from src.live import usde as U # noqa: E402
def _cfg(**kw) -> dict:
base = dict(max_notional_per_asset_frac=0.5, disaster_sl_pct=0.3)
base.update(kw)
return base
# ---------------------------------------------------------------- T1: scala assente
def test_T1_scala_assente_e_frac_uguale_al_weight():
leva, come = B.leva_tetto(_cfg())
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
assert "scala 1.0" in come
# ---------------------------------------------------------------- T2: il caso dell'issue
def test_T2_scala_1_25_alza_la_leva_e_il_cuscino_usde(tmp_path, monkeypatch):
"""A scala 1,25 il cuscino USDC deve valere 0,375 dell'equity, non 0,30 (#28)."""
cfg = _cfg(book_scale_k=1.25)
leva, _ = B.leva_tetto(cfg)
assert leva == pytest.approx(1.25)
# frazione_cuscino legge da CFG_PATH: si monkeypatcha il file, come fanno gli altri test
# su usde/_scala (grep "CFG_PATH\|CONFIG" tests/).
cpath = tmp_path / "live.json"
cpath.write_text(json.dumps(cfg))
monkeypatch.setattr(U, "CFG_PATH", cpath)
f, come = U.frazione_cuscino()
assert f == pytest.approx(0.375)
assert "SL 30%" in come
# ---------------------------------------------------------------- T3: fuori scaletta non solleva
def test_T3_scala_fuori_scaletta_non_solleva_e_torna_il_tetto_di_codice():
"""cuscino_watch non deve morire proprio quando la config e' sbagliata (verifica fable B5)."""
leva, come = B.leva_tetto(_cfg(book_scale_k=1.1))
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
assert "tetto di codice" in come
# ---------------------------------------------------------------- T4: frac assente
def test_T4_frac_assente_usa_weight():
cfg = dict(disaster_sl_pct=0.3) # niente max_notional_per_asset_frac
leva, come = B.leva_tetto(cfg)
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
assert f"frac {B.WEIGHT}" in come
# ---------------------------------------------------------------- T5: il tronco al tetto di codice
def test_T5_leva_oltre_il_tetto_di_codice_viene_troncata(monkeypatch):
"""Verifica diretta del troncamento. Con WEIGHT alzato il prodotto supera LEVA_LORDA_MAX pur
restando `_scala` silenziosa (frac ASSENTE dalla config -> il suo controllo sul prodotto non
scatta, perche' guarda solo `max_notional_per_asset_frac` quando e' dichiarato)."""
monkeypatch.setattr(B, "WEIGHT", 0.9)
leva, come = B.leva_tetto(dict(book_scale_k=1.25)) # frac assente -> usa WEIGHT = 0.9
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
assert "troncato" in come
# ---------------------------------------------------------------- T6: il giornale usa la stessa leva_tetto
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
giorno_in_corso=False, giri_mancanti=0))
def _libro(**asset):
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
azione="HOLD (a target)"), **v) for k, v in asset.items()}
lordo = sum(abs(v["posizione"]) for v in a.values())
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
def _pnl(**kw):
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
cumulato_da_arming=0.0, letture_equity=24)
return {**base, **kw}
def test_T6_regola_leva_margine_positivo_a_scala_1_25(db, monkeypatch, tmp_path):
"""lv=1.2 su un tetto di 1.25x (frac 0.5, scala 1.25): margine positivo, non negativo."""
(tmp_path / "config").mkdir()
(tmp_path / "config" / "live.json").write_text(
json.dumps(dict(max_notional_per_asset_frac=0.5, book_scale_k=1.25)))
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path)
lb = _libro(BTC=dict(posizione=360.0), ETH=dict(posizione=360.0)) # 720/600 = 1.2x
assert lb["leva_lorda"] == pytest.approx(1.2)
r = J.lettura(db, _voce(libro=lb, pnl=_pnl()))
riga = next(x for x in r if x["id"] == "leva")
assert "1.25x" in riga["testo"] and "margine 0.05x" in riga["testo"]
# ---------------------------------------------------------------- A7 (#35): min_order_usd da config
def test_A7_build_book_order_rispetta_min_usd_di_config():
"""book_report legge min_order_usd da config (default 5.0 se assente, come book_execute.py).
Qui si verifica direttamente build_book_order, che e' il punto dove il valore viene usato:
con min_usd=20 un delta di $10 non genera ordine, con min_usd=5 (il default di oggi) si."""
order_stretto = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=20.0)
assert order_stretto is None # |delta|=10 < 20: sotto soglia col tetto alzato
order_default = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=5.0)
assert order_default is not None # |delta|=10 >= 5: lo stesso caso, oggi, genera ordine
+140
View File
@@ -0,0 +1,140 @@
"""Test del giro ORARIO di monitor_health (#30).
I sorveglianti a max_age<=6h (cuscino_watch, venue_watch, collect_chain) girano alle 00:30 col
report giornaliero e basta: un guasto si scopre fino a 24h dopo. Qui si guarda ogni ora, ma si
notifica solo cio' che e' NUOVO rispetto al marcatore "gia' detto" — altrimenti un monitor fermo
manderebbe lo stesso Telegram ogni ora per giorni.
Le funzioni PURE (filtro per max_age, chiave dell'alert, chi e' nuovo, il marcatore aggiornato)
stanno in src/live/monitor_health.py e si testano su righe finte, senza toccare disco o rete.
L'orchestrazione (leggere/scrivere il marcatore, chiamare notify) sta nello script e si testa qui
sotto con monkeypatch — NIENTE Telegram vero."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live.monitor_health import ( # noqa: E402
MonitorSpec,
alert_nuovi,
chiave_alert,
marcatore_aggiornato,
monitors_orari,
)
import monitor_health as MH # noqa: E402 (scripts/live/monitor_health.py)
# righe finte, nella forma che assess()/check_all() restituiscono davvero
ROW_FERMO = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=3, age_h=4.0,
coverage=0.9, expected=3, status="FERMO", why="ultima barra 4h fa (limite 3h)")
ROW_OK = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=10, age_h=0.2,
coverage=1.0, expected=10, status="OK", why="")
ROW_NUOVO = dict(name="venue_watch", gate=None, n_bars=1, age_h=0.1, coverage=None,
expected=None, status="NUOVO", why="1 barra: non c'e' abbastanza serie")
# ===========================================================================
# funzioni PURE — filtro per max_age (src/live/monitor_health.py)
# ===========================================================================
def test_il_filtro_deriva_dalla_tabella_non_da_una_lista_di_nomi():
specs = (
MonitorSpec("a", "a", "runs.jsonl", 1.0, max_age_h=3.0),
MonitorSpec("b", "b", "runs.jsonl", 1.0, max_age_h=6.0), # al bordo: dentro
MonitorSpec("c", "c", "runs.jsonl", 24.0, max_age_h=30.0),
)
assert {m.name for m in monitors_orari(specs)} == {"a", "b"}
def test_i_tre_orari_di_oggi_sono_quelli_noti():
"""Se la tabella cambia questo test lo dice: e' l'ancora fra il registro e il flag."""
assert {m.name for m in monitors_orari()} == {"collect_chain", "cuscino_watch", "venue_watch"}
# ===========================================================================
# funzioni PURE — chiave, nuovi, marcatore
# ===========================================================================
def test_chiave_e_nome_piu_stato_non_il_testo():
"""Il testo (`why`) contiene l'eta', che cambia ogni ora anche se il guasto e' lo stesso."""
assert chiave_alert(ROW_FERMO) == "venue_watch:FERMO"
piu_vecchio = {**ROW_FERMO, "age_h": 9.0, "why": "ultima barra 9h fa (limite 3h)"}
assert chiave_alert(piu_vecchio) == chiave_alert(ROW_FERMO)
def test_alert_nuovo_se_la_chiave_non_e_nel_marcatore():
assert alert_nuovi([ROW_FERMO], set()) == [ROW_FERMO]
def test_lo_stesso_alert_al_secondo_giro_non_e_piu_nuovo():
assert alert_nuovi([ROW_FERMO], {"venue_watch:FERMO"}) == []
def test_nuovo_non_e_un_alert_da_notificare():
assert alert_nuovi([ROW_NUOVO], set()) == []
def test_il_marcatore_aggiunge_la_chiave_di_un_alert():
assert marcatore_aggiornato([ROW_FERMO], set()) == {"venue_watch:FERMO"}
def test_il_ritorno_a_ok_toglie_la_chiave_dal_marcatore():
"""Cosi' un nuovo guasto sullo stesso monitor riparte con un nuovo avviso."""
assert marcatore_aggiornato([ROW_OK], {"venue_watch:FERMO"}) == set()
# ===========================================================================
# orchestrazione — scripts/live/monitor_health.py main_orari(), NIENTE Telegram
# ===========================================================================
def test_notify_fallito_non_scrive_il_marcatore(tmp_path, monkeypatch):
"""Stessa regola di fee_watch #29: niente 'gia' detto' su un invio fallito, altrimenti il
prossimo giro non ritenterebbe."""
detto = tmp_path / "detto.json"
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append((a, k)), False)[1])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
rc = MH.main_orari()
assert rc == 2 # c'e' un alert
assert len(chiamate) == 1 # e si e' provato a notificarlo
assert not detto.exists() # ma l'invio e' fallito: nessun marcatore
def test_notify_riuscito_scrive_il_marcatore_e_non_rinotifica(tmp_path, monkeypatch):
detto = tmp_path / "detto.json"
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
monkeypatch.setattr(MH, "notify", lambda *a, **k: True)
rc1 = MH.main_orari()
assert rc1 == 2
assert json.loads(detto.read_text())["detti"] == ["venue_watch:FERMO"]
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
rc2 = MH.main_orari()
assert rc2 == 2 # l'alert c'e' ancora (anche se gia' detto: exit 2 comunque)
assert chiamate == [] # ma non si rimanda lo stesso Telegram
def test_tornato_ok_il_giro_dopo_pulisce_il_marcatore(tmp_path, monkeypatch):
detto = tmp_path / "detto.json"
detto.write_text(json.dumps({"detti": ["venue_watch:FERMO"], "ts": "2026-09-27T00:00:00Z"}))
monkeypatch.setattr(MH, "DETTO", detto)
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_OK])
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
chiamate = []
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
rc = MH.main_orari()
assert rc == 0
assert chiamate == [] # OK non notifica mai
assert json.loads(detto.read_text())["detti"] == []
+64 -1
View File
@@ -68,14 +68,65 @@ def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path):
def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch): def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch):
"""CLAUDE.md e' la prima voce e ha un tetto SUO (MAX_CHARS_CLAUDE_MD, #37 C12): si monkeypatcha
quello, non il MAX_CHARS_VOCE generico, che non lo tocca piu'."""
r = _radice(tmp_path) r = _radice(tmp_path)
(r / "CLAUDE.md").write_text("x" * 1000) (r / "CLAUDE.md").write_text("x" * 1000)
monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100) monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 100)
n, t, tr = R.materiale(r, ADESSO)[0] n, t, tr = R.materiale(r, ADESSO)[0]
assert tr and "[… troncato a 100 caratteri su 1,000]" in t assert tr and "[… troncato a 100 caratteri su 1,000]" in t
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO) assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
def test_claude_md_ha_un_tetto_suo_distinto_dal_generico(tmp_path, monkeypatch):
"""#37 C12: CLAUDE.md sfiora MAX_CHARS_VOCE con un margine minimo — deve avere un tetto suo
piu' largo, cosi' un piccolo aggiornamento non lo tronca."""
r = _radice(tmp_path)
n_chars = R.MAX_CHARS_VOCE + 1_000 # sopra il generico, ma sotto MAX_CHARS_CLAUDE_MD
assert n_chars < R.MAX_CHARS_CLAUDE_MD
(r / "CLAUDE.md").write_text("x" * n_chars)
n, t, tr = R.materiale(r, ADESSO)[0]
assert n.startswith("CLAUDE.md")
assert not tr, "sotto il tetto SUO (150k): non deve troncare, anche se supera i 90k generici"
def test_voce_vicina_al_tetto_e_avvisata_nel_prompt_e_nel_rapporto(tmp_path, monkeypatch):
"""#37 C12: al 95% del tetto (non ancora troncata) l'avviso entra nel testo, e da li' arriva
sia al prompt del revisore sia alla tabella del rapporto — come [TRONCATO] oggi."""
r = _radice(tmp_path)
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 10_000)
(r / "CLAUDE.md").write_text("x" * 9_600) # 96% di 10.000, margine largo per la nota
n, t, tr = R.materiale(r, ADESSO)[0]
assert not tr, "non e' troncata: e' vicina"
assert R._MARCA_VICINO_TETTO in t
assert "[VICINO AL TETTO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
rap = R.rapporto("revisione", None, "ok", [], "claude-fable-5-1", ADESSO, [(n, t, tr)])
assert "| quasi |" in rap
def test_voce_lontana_dal_tetto_non_e_avvisata(tmp_path):
voci = R.materiale(_radice(tmp_path), ADESSO)
claude_md = [t for n, t, tr in voci if n.startswith("CLAUDE.md")][0]
assert R._MARCA_VICINO_TETTO not in claude_md
def test_il_totale_resta_sotto_il_tetto_anche_se_la_somma_dei_tetti_lo_supera(tmp_path, monkeypatch):
"""#37 C12: CLAUDE.md (150k) + il resto delle voci (90k l'una) puo' superare facilmente i 400k
di MAX_CHARS_TOTALE in somma — il tetto totale e' applicato A PARTE, a valle, e resta il vero
vincolo: non deve MAI sfondare, qualunque sia il tetto di ogni singola voce."""
r = _radice(tmp_path)
(r / "CLAUDE.md").write_text("x" * R.MAX_CHARS_CLAUDE_MD) # al SUO tetto: 150k
j = r / "docs" / "journal"
for i in range(1, 10): # sovrascrive i giornali della finestra
g = (ADESSO - timedelta(days=i)).date().isoformat()
(j / f"{g}.md").write_text("y" * R.MAX_CHARS_VOCE) # ognuno al SUO tetto: 90k
voci = R.materiale(r, ADESSO)
tot = sum(len(t) for _, t, _ in voci)
somma_tetti = sum(R._cap_voce(n) for n, _, _ in voci)
assert somma_tetti > R.MAX_CHARS_TOTALE, "il caso e' quello che sfonderebbe se il totale non fosse a parte"
assert tot <= R.MAX_CHARS_TOTALE
def test_il_prompt_porta_le_regole_e_la_data(tmp_path): def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO) p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
@@ -132,6 +183,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096 assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
def test_taglio_a_cavallo_di_una_entita_resta_HTML_valido():
"""(#35 C13) Un '&' esattamente al taglio non deve spezzarsi in un'entita' a meta' (es.
'&am'): il taglio e' sul GREZZO, prima di html.escape, quindi il carattere e' incluso o
escluso per intero — mai spezzato a meta' entita'."""
sintesi = "a" * (R.LIMITE_TELEGRAM - 1) + "&resto che verra' tagliato via di sicuro"
tg = R.per_telegram(_revisione(sintesi=sintesi), None, "ok", ADESSO, Path("p.md"))
assert "[… sintesi tagliata" in tg
corpo = tg.split("\n\n", 1)[1].split("\n[…", 1)[0]
assert corpo == "a" * (R.LIMITE_TELEGRAM - 1) + "&amp;" # il '&' e' incluso PER INTERO
assert "&am;" not in tg and "resto che verra'" not in tg # niente entita' a meta', niente coda
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no # ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
def _albero(root: Path) -> set[str]: def _albero(root: Path) -> set[str]:
+101 -1
View File
@@ -7,6 +7,7 @@ e non invia niente in dry-run.
from __future__ import annotations from __future__ import annotations
import sys import sys
from datetime import date
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -21,8 +22,33 @@ telegram_daily = pytest.importorskip("telegram_daily")
def test_build_non_solleva_e_ha_le_sezioni(): def test_build_non_solleva_e_ha_le_sezioni():
txt = telegram_daily.build() txt = telegram_daily.build()
assert isinstance(txt, str) and len(txt) > 50 assert isinstance(txt, str) and len(txt) > 50
for sezione in ("Conto", "Perche' non opera", "Ordini", "Monitor in osservazione"): for sezione in ("Conto", "Ordini", "Monitor in osservazione"):
assert sezione in txt, f"sezione mancante: {sezione}" assert sezione in txt, f"sezione mancante: {sezione}"
# (#35 C14) l'header sul trend e' condizionale: esattamente uno dei due, non piu' fisso.
assert ("Perche' non opera" in txt) != ("Stato trend" in txt)
def test_header_perche_non_opera_solo_se_il_libro_e_flat(monkeypatch):
"""(#35 C14) L'header fisso mentiva quando il libro NON e' fermo (posizioni o target != 0):
con flat=True resta "Perche' non opera"."""
monkeypatch.setattr(telegram_daily, "book_state", lambda: ([" equity $1.00"], True))
assert "Perche' non opera" in telegram_daily.build()
def test_header_neutro_se_il_libro_non_e_flat(monkeypatch):
monkeypatch.setattr(telegram_daily, "book_state",
lambda: ([" BTC: pos $300 → target $300"], False))
txt = telegram_daily.build()
assert "Stato trend" in txt and "Perche' non opera" not in txt
def test_header_neutro_se_il_libro_non_e_leggibile(monkeypatch):
"""Se book_state non sa dire se e' flat (eccezione -> None), niente header "non opera"
dichiarato senza saperlo davvero: si usa il neutro."""
monkeypatch.setattr(telegram_daily, "book_state",
lambda: ([" ⚠️ stato libro non leggibile: Errore"], None))
txt = telegram_daily.build()
assert "Stato trend" in txt and "Perche' non opera" not in txt
def test_nessuna_scrittura_ne_ordine(): def test_nessuna_scrittura_ne_ordine():
@@ -52,3 +78,77 @@ def test_convenzione_trend_e_documentata():
"""La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su: """La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su:
se sparisce dal codice, il report mentirebbe sul 'quanto manca'.""" se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__ assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
def test_soglia_accensione_2_negativi_1_positivo():
"""2 negativi + 1 positivo: basta 1 flip -> soglia = il riferimento negativo PIU' BASSO."""
r = telegram_daily._soglia_accensione([-1, -1, 1], [100.0, 200.0])
assert r == 100.0
def test_soglia_accensione_3_negativi():
"""3 negativi: servono 2 flip -> soglia = il SECONDO riferimento negativo piu' basso, non il
minimo (il bug di #35 C3: superare solo il minimo lascia la media <= 0)."""
r = telegram_daily._soglia_accensione([-1, -1, -1], [100.0, 200.0, 300.0])
assert r == 200.0
def test_soglia_accensione_1_negativo_2_positivi_gia_acceso():
"""1 negativo + 2 positivi: la media e' gia' positiva (TP01 > 0) -> nessuna soglia da dare."""
assert telegram_daily._soglia_accensione([-1, 1, 1], [100.0]) is None
def test_soglia_accensione_nessun_negativo():
"""Tutti positivi: nessun riferimento negativo, nessuna soglia."""
assert telegram_daily._soglia_accensione([1, 1, 1], []) is None
def test_soglia_accensione_servono_tutti_i_flip():
"""2 negativi su 2 (nessun positivo): servono ENTRAMBI i flip -> k == len(neg_refs), soglia
= il piu' alto dei due (l'ultimo a bastare), non il minimo."""
r = telegram_daily._soglia_accensione([-1, -1], [200.0, 100.0])
assert r == 200.0
# ---------------------------------------------------------------- (#35 C14) gate: data, formato
def test_gate_date_legge_da_monitor_health():
"""La data non e' una seconda dichiarazione: viene da monitor_health.MONITORS (`gate`),
la stessa tabella che gli alert dei sorveglianti citano come "METTE A RISCHIO il gate"."""
from src.live.monitor_health import MONITORS
m = next(x for x in MONITORS if x.name == "paper_statarb")
assert m.gate[:10] == "2026-09-27"
assert telegram_daily._gate_date("paper_statarb", date(2030, 1, 1)) == date(2026, 9, 27)
m2 = next(x for x in MONITORS if x.name == "paper_xsr")
assert telegram_daily._gate_date("paper_xsr", date(2030, 1, 1)) == date.fromisoformat(m2.gate[:10])
def test_gate_date_usa_la_cablata_se_il_monitor_non_esiste():
"""Rete di sicurezza: se il nome non e' in MONITORS (o cambia), non si inventa una data."""
assert telegram_daily._gate_date("non_esiste_piu'", date(2030, 1, 1)) == date(2030, 1, 1)
def test_fmt_giorni_tre_casi_mai_il_doppio_segno():
"""(#35 C14) giorni>0 -> '-Ng'; 0 -> 'oggi'; <0 -> 'scaduto da Ng'. Mai '(−-1g)'."""
assert telegram_daily._fmt_giorni(5) == "-5g"
assert telegram_daily._fmt_giorni(0) == "oggi"
assert telegram_daily._fmt_giorni(-1) == "scaduto da 1g"
assert "-1g" not in telegram_daily._fmt_giorni(-1).replace("scaduto da 1g", "") # niente doppio segno
def test_monitors_formatta_i_tre_casi_del_giorno(monkeypatch, tmp_path):
"""I rami di monitors() (barra assente qui: file inesistenti in tmp_path) usano tutti
_fmt_giorni, non il vecchio '(−{giorni}g)' cablato: futuro, oggi e passato si distinguono."""
from datetime import timedelta
oggi = date.today()
monkeypatch.setattr(telegram_daily, "ROOT", tmp_path)
monkeypatch.setattr(telegram_daily, "GATES",
[("FUTURO", oggi + timedelta(days=10), "data/nonexiste1/r.jsonl", "k"),
("OGGI", oggi, "data/nonexiste2/r.jsonl", "k"),
("PASSATO", oggi - timedelta(days=3), "data/nonexiste3/r.jsonl", "k")])
righe = telegram_daily.monitors()
assert "(-10g)" in righe[0]
assert "(oggi)" in righe[1]
assert "(scaduto da 3g)" in righe[2]
assert "−-" not in "".join(righe) and "(−" not in "".join(righe) # mai il doppio segno
+120
View File
@@ -0,0 +1,120 @@
"""scripts/live/trades_db.py:sync() — controllo di conteggio jsonl-vs-DB (#37 C11, #35 A9).
Dal 2026-08-24T01:07Z (`tradesdb.CUTOFF_TS_VERO`) `book_executions.jsonl` porta l'ora VERA del
fill: un conteggio diverso da quello del DB in quella finestra segnala che una delle due fonti
sta perdendo fill. `sync()` non ripara (come `reconcile()`): riporta e notifica UNA volta per
episodio, con lo stesso marcatore-solo-su-invio-riuscito di fee_watch (#37 B9).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live import tradesdb as T # noqa: E402
import trades_db as TD # noqa: E402
@pytest.fixture()
def redirige(monkeypatch, tmp_path):
"""DB, cron_book.log e jsonl finti in tmp_path: non si tocca mai il libro vero."""
reale = T.connect
monkeypatch.setattr(T, "connect", lambda *a, **k: reale(tmp_path / "t.db"))
monkeypatch.setattr(T, "CRON_LOG", tmp_path / "cron_book.log") # assente -> nessun fill dal log
monkeypatch.setattr(T, "EXEC_JSONL", tmp_path / "book_executions.jsonl")
return tmp_path
def _semina_db(tmp_path, n, ts=None):
ts = ts or T.CUTOFF_TS_VERO
con = T.connect(tmp_path / "t.db")
fills = [T.Fill(ts_utc=ts, asset="BTC", side="buy", qty=0.001 * (i + 1), price=70000.0, fee=0.01)
for i in range(n)]
T.upsert_fills(con, fills)
con.close()
def _scrivi_jsonl(tmp_path, n, ts=None):
ts = ts or T.CUTOFF_TS_VERO
righe = [dict(ts_utc=ts, asset="BTC", side="buy", filled=0.001 * (i + 1), price=70000.0, fee=0.01)
for i in range(n)]
(tmp_path / "book_executions.jsonl").write_text(
"\n".join(json.dumps(r) for r in righe) + ("\n" if righe else ""))
def test_conteggi_uguali_non_notifica(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 2)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert inviato == []
assert r["jsonl_dal_24_08"] == r["db_dal_24_08"] == 2
def test_conteggi_diversi_notifica_una_volta_per_episodio(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert len(inviato) == 1, "conteggi diversi: un invio"
assert r["jsonl_dal_24_08"] == 3 and r["db_dal_24_08"] == 2
# secondo giro, stessa divergenza -> nessun invio aggiuntivo (marcatore gia' scritto)
r2 = TD.sync(verbose=False)
assert len(inviato) == 1, "stesso episodio: nessun secondo invio"
assert r2["jsonl_dal_24_08"] == 3 and r2["db_dal_24_08"] == 2
def test_invio_fallito_non_marca_ritenta_al_giro_dopo(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or False)
TD.sync(verbose=False)
TD.sync(verbose=False)
assert len(inviato) == 2, "invio fallito -> marcatore non scritto, si ritenta ogni giro"
def test_conteggio_rientrato_azzera_il_marcatore(monkeypatch, redirige):
tmp_path = redirige
_semina_db(tmp_path, 2)
_scrivi_jsonl(tmp_path, 3)
monkeypatch.setattr(TD, "notify", lambda *a, **k: True)
TD.sync(verbose=False) # divergenza detta
# il jsonl si allinea (es. arrivato il fill mancante) -> episodio chiuso
_scrivi_jsonl(tmp_path, 2)
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
TD.sync(verbose=False)
assert inviato == [], "conteggi tornati uguali: nessun invio da fare"
# una NUOVA divergenza deve poter notificare di nuovo (il marcatore era stato azzerato)
_scrivi_jsonl(tmp_path, 4)
TD.sync(verbose=False)
assert len(inviato) == 1
def test_fill_prima_del_24_08_non_entra_nel_conteggio(monkeypatch, redirige):
"""Prima di CUTOFF_TS_VERO il ts_utc del jsonl e' la barra di segnale, non l'ora vera: non
e' comparabile col DB, e non deve contare come divergenza."""
tmp_path = redirige
_semina_db(tmp_path, 0)
_scrivi_jsonl(tmp_path, 2, ts="2026-08-10T00:00:00+00:00") # prima della soglia
inviato = []
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
r = TD.sync(verbose=False)
assert inviato == []
assert r["jsonl_dal_24_08"] == 0 and r["db_dal_24_08"] == 0
+147
View File
@@ -0,0 +1,147 @@
"""tradesdb.arricchisci_da_jsonl (#40, TradingBook#11): le righe `ts_source='cron_log'` dal
24/08 (`CUTOFF_TS_VERO`) prendono l'ora vera, il prezzo pieno e `pos_after` dal jsonl, SOLO
quando l'abbinamento (asset, side, qty, finestra di 15 minuti) e' univoco. `fill_id` e' la
chiave della riga: l'arricchimento aggiorna le colonne con un UPDATE per `fill_id`, mai la
chiave stessa.
Nessun test tocca la rete o il libro vero: DB e righe jsonl sono finti, in tmp_path.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live import tradesdb as T # noqa: E402
# ts di riferimento: subito dopo la soglia, cosi' i fill sono sempre candidati.
T0 = "2026-08-24T02:00:00+00:00"
@pytest.fixture()
def db(tmp_path):
con = T.connect(tmp_path / "t.db")
yield con
con.close()
def _f(ts, asset="BTC", side="buy", qty=0.005, price=70000.0, fee=0.02):
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee)
def _riga_jsonl(ts, asset="BTC", side="buy", filled=0.005, price=70123.4, pos_after=None):
return dict(ts_utc=ts, asset=asset, side=side, filled=filled, price=price, pos_after=pos_after)
def test_abbinamento_unico_aggiorna_e_non_cambia_fill_id(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
fill_id_prima = fill.fill_id
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)] # +5 min, in finestra
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (1, 0)
riga = db.execute("SELECT * FROM fills WHERE fill_id=?", (fill_id_prima,)).fetchone()
assert riga is not None, "il fill_id originale resta la chiave: l'UPDATE non lo cambia"
assert riga["ts_source"] == "jsonl"
assert riga["ts_utc"] == "2026-08-24T02:05:00+00:00"
assert riga["price"] == 70123.4
assert riga["pos_after"] == 555.0
# nessuna riga in piu' o in meno: l'arricchimento aggiorna, non inserisce.
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 1
def test_pos_after_none_nel_jsonl_non_sovrascrive_quello_gia_nel_db(db):
"""(#40, punto 1) `pos_after=None` nella riga jsonl non e' un valore: la colonna resta
quella che aveva (qui: None dal cron_log, ma la regola e' 'non toccare', non 'azzerare')."""
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=None)]
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (1, 0)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "jsonl" and riga["price"] == 70123.4
assert riga["pos_after"] is None
def test_due_jsonl_compatibili_e_ambiguo_non_si_tocca(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:03:00+00:00", price=70100.0),
_riga_jsonl("2026-08-24T02:07:00+00:00", price=70200.0)] # entrambe in finestra
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (0, 1)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log" # non toccata
assert riga["ts_utc"] == T0
assert riga["price"] == 70000.0
def test_secondo_giro_zero_aggiornati_idempotente(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)]
assert T.arricchisci_da_jsonl(db, righe) == (1, 0)
# la riga e' gia' ts_source='jsonl': non e' piu' candidata, anche con le stesse righe jsonl.
assert T.arricchisci_da_jsonl(db, righe) == (0, 0)
assert db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"] == 1
def test_riga_prima_del_cutoff_non_e_candidata(db):
ts_prima = "2026-08-10T00:00:00+00:00" # prima di CUTOFF_TS_VERO (24/08)
fill = _f(ts_prima)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-10T00:05:00+00:00", pos_after=1.0)] # abbinamento perfetto
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
assert (aggiornate, ambigue) == (0, 0) # non e' ambigua: non e' nemmeno candidata
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log" and riga["ts_utc"] == ts_prima and riga["price"] == 70000.0
def test_fuori_dalla_finestra_dei_15_minuti_non_abbina(db):
fill = _f(T0)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:16:00+00:00", pos_after=1.0)] # +16 min: fuori finestra
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
riga = db.execute("SELECT * FROM fills").fetchone()
assert riga["ts_source"] == "cron_log"
def test_qty_diversa_non_abbina(db):
fill = _f(T0, qty=0.005)
T.upsert_fills(db, [fill])
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", filled=0.006, pos_after=1.0)]
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
def test_record_jsonl_usato_una_volta_non_si_riusa(db):
"""Due fill IDENTICI nello stesso giro (stesso asset/side/qty, ts diversi) non devono
finire abbinati alla stessa riga jsonl: la prima riga jsonl la prende un solo fill_id."""
f1 = _f("2026-08-24T02:00:00+00:00")
f2 = _f("2026-08-24T02:01:00+00:00") # ts diverso -> fill_id diverso, stesso asset/side/qty
T.upsert_fills(db, [f1, f2])
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=1.0)] # UNA sola riga jsonl
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
# un solo candidato jsonl per due righe DB candidate: la prima (ordinata per ts_utc) lo
# prende, la seconda resta senza candidati liberi -> ambigua (zero disponibili).
assert aggiornate == 1
assert ambigue == 1
presi = db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"]
assert presi == 1
+12 -32
View File
@@ -8,17 +8,17 @@ Cosa difendono, in ordine di gravita':
da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta. da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta.
2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto. 2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto.
3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5). 3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5).
4. **P9**: `atteso` declassa la gravita' SOLO su evidenza di manutenzione, e SOLO dentro lo slot. 4. **`atteso` e' sempre False** (#35 B11): il ramo che lo declassava (finestra di release del
martedi') e' stato rimosso perche' l'unico chiamante (cron :47) non ci cadeva mai dentro.
""" """
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose, from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose,
errori_dal_report, in_release_window) errori_dal_report)
MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":' MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":'
'{"message":"system_maintenance","code":11051}}') '{"message":"system_maintenance","code":11051}}')
@@ -94,39 +94,19 @@ def test_la_prova_viaggia_sempre_con_il_verdetto():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. P9 — `atteso` declassa, ma solo su EVIDENZA e solo DENTRO lo slot. # 2. `atteso` e' sempre False (#35 B11) — nessun verdetto, manutenzione compresa, si declassa.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_finestra_di_release_martedi_nove_utc(): def test_manutenzione_non_si_declassa_piu_atteso_sempre_falso():
dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero """Il ramo che declassava (finestra di release del martedi') e' rimosso: una manutenzione
assert in_release_window(dentro) is True riconosciuta dalla sonda resta comunque `atteso is False` e alla gravita' piena."""
assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True d = diagnose([], sonda=_sonda_manutenzione)
assert in_release_window(datetime(2026, 8, 25, 9, 29, tzinfo=timezone.utc)) is True
assert in_release_window(datetime(2026, 8, 25, 9, 30, tzinfo=timezone.utc)) is False # sforata
assert in_release_window(datetime(2026, 8, 25, 8, 59, tzinfo=timezone.utc)) is False
assert in_release_window(datetime(2026, 8, 26, 9, 7, tzinfo=timezone.utc)) is False # mercoledi'
def test_manutenzione_dentro_lo_slot_e_attesa_e_declassa():
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is True
assert d.gravita == "ℹ️" # declassato, NON silenziato
assert "ATTESO" in d.riga()
def test_manutenzione_che_sfora_lo_slot_torna_rumorosa():
"""Il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora: alle 10:07 la
manutenzione non era piu' l'evento annunciato, ed e' di nuovo una notizia."""
d = diagnose([], now=datetime(2026, 8, 18, 10, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is False assert d.verdetto == V_MANUTENZIONE and d.atteso is False
assert d.gravita != "ℹ️" assert d.gravita == "⚠️"
assert "ATTESO" not in d.riga()
def test_un_guasto_vero_di_martedi_mattina_non_viene_declassato(): def test_un_guasto_vero_non_e_mai_atteso():
"""Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
rotto dentro lo slot di release deve restare al massimo della gravita'."""
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), sonda=_sonda_ok)
assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑" assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"