Compare commits
19 Commits
1a7f670966
...
841d4c78cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 841d4c78cd | |||
| 0e6cf17081 | |||
| dab8a88e1c | |||
| ed4f8fd39b | |||
| 8405fe2a7d | |||
| 7234625863 | |||
| 9ce924d97c | |||
| 7c72308852 | |||
| 65a7f0d8fc | |||
| f79fb54221 | |||
| 4725839041 | |||
| 5acc31e390 | |||
| dda5d41f38 | |||
| f0193f013d | |||
| 0c19c804d1 | |||
| 2bf4f9cceb | |||
| b8c2bba320 | |||
| ac21e1c97d | |||
| cfc42653cc |
@@ -73,7 +73,8 @@ def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str],
|
||||
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
|
||||
telegram: bool = True) -> str:
|
||||
voce = J.costruisci(con, g)
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
if not secco: # --secco: stampa e NON salva (#34) — niente scrittura
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
pagina = J.rendi_markdown(con, voce)
|
||||
st = storico(con, g)
|
||||
prompt = A.costruisci_prompt(pagina, st)
|
||||
|
||||
+103
-28
@@ -46,7 +46,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import ScalaNonAutorizzata, book_report
|
||||
from src.live.execution import DeribitTrader
|
||||
from src.live.execution import DeribitTrader, RebalanceParziale
|
||||
from src.live.notifier import notify
|
||||
from src.live.venue_probe import diagnose, errori_dal_report
|
||||
|
||||
@@ -85,6 +85,31 @@ def log_event(rec: dict):
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int):
|
||||
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la
|
||||
posizione risultante: un ordine eseguito non deve dipendere dalla lettura della posizione
|
||||
successiva per essere salvato (#23 — un 502 su `position_usd()` dopo un fill riuscito lo
|
||||
faceva sparire dal log, perche' la lettura veniva PRIMA del giro `for f in fills`).
|
||||
|
||||
`pos_after` e' sempre None qui: la posizione dopo il rebalance si legge (best-effort) DOPO
|
||||
aver registrato tutti i fill, in `_run` — vedi il commento li'."""
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=None,
|
||||
tp_frac=tp_frac, skh_sign=skh_sign))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
|
||||
|
||||
def _run():
|
||||
cfg = load_config()
|
||||
want_execute = "--execute" in sys.argv[1:]
|
||||
@@ -129,8 +154,10 @@ def _run():
|
||||
print(f" ultima barra : {r['last_data']}\n")
|
||||
|
||||
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}")
|
||||
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
||||
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
|
||||
# quel segnale ignoto (modo SOLO PROTEZIONE, sotto, che porta anche questo errore su
|
||||
# Telegram — qui solo il print, per non mandare due notify per lo stesso guasto).
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}")
|
||||
|
||||
# --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) --------------------------
|
||||
# `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e
|
||||
@@ -220,10 +247,27 @@ def _run():
|
||||
"azione": "rebuild_history.py --asset BTC ETH"})
|
||||
return
|
||||
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
||||
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
||||
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
||||
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
|
||||
# (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
|
||||
# questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
|
||||
print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}")
|
||||
|
||||
# --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------
|
||||
# Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda
|
||||
# ordini di ribilancio (dimensionare un ordine su un numero che non si conosce e' peggio che
|
||||
# non ordinare) -> ma CONTROLLA LO STESSO il disaster-SL di ogni asset: la protezione dipende
|
||||
# da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia'
|
||||
# bloccando l'esecuzione, qui senza saltare la protezione.
|
||||
solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error")
|
||||
# Calcolato una volta sola: serve sia nell'azione per-asset del ciclo sotto sia nel
|
||||
# riepilogo finale (#21, #22) — un solo motivo, non due troncamenti che potrebbero divergere.
|
||||
motivo_breve = (solo_protezione if solo_protezione and len(solo_protezione) <= 60
|
||||
else (solo_protezione[:60] + "…" if solo_protezione else None))
|
||||
if solo_protezione and do_execute:
|
||||
titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione"
|
||||
if r.get("eq_fallback") else
|
||||
"⚠️ BOOK LIVE — SKH non calcolato: nessun ordine, solo protezione")
|
||||
notify(titolo, {"nota": solo_protezione})
|
||||
|
||||
# Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento
|
||||
# NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il
|
||||
@@ -250,13 +294,18 @@ def _run():
|
||||
# non deve togliere la rete di sicurezza all'altro.
|
||||
falliti: list[str] = []
|
||||
scoperti: list[str] = []
|
||||
non_verificati: list[str] = []
|
||||
for a in r["assets"]:
|
||||
asset, inst = a["asset"], a["instrument"]
|
||||
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||
sk = a["skh_state"]
|
||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||
order = a["order"]
|
||||
if order is None:
|
||||
if solo_protezione:
|
||||
# (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e'
|
||||
# "non tocco perche' non so quanto vale il conto / qual e' il segnale".
|
||||
act = f"HOLD (solo protezione: {motivo_breve})"
|
||||
elif order is None:
|
||||
act = "HOLD (a target)"
|
||||
elif order.get("is_close"):
|
||||
act = f"CLOSE ${cur:,.0f}"
|
||||
@@ -268,26 +317,32 @@ def _run():
|
||||
f"| pos ${cur:+,.0f} -> {act}")
|
||||
|
||||
try:
|
||||
if do_execute and order is not None:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
newpos = trader.position_usd(inst)
|
||||
if do_execute and order is not None and not solo_protezione:
|
||||
# (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
|
||||
# o mark sono <=0 solleva ValueError: e' giusto, finisce nell'isolamento per asset
|
||||
# (except sotto), non deve fermare il giro intero.
|
||||
trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark)
|
||||
try:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
except RebalanceParziale as rp:
|
||||
# (#23) almeno un fill e' passato prima che il resto del passo sollevasse
|
||||
# (es. flip: close ok, open KO) -> si registra quel fill SUBITO (non e' un
|
||||
# fill perso), poi si rilancia la causa nell'handler di isolamento esistente.
|
||||
for f in rp.fills:
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
||||
raise rp.causa
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
||||
# newpos e' un RECONCILE informativo, letto DOPO aver registrato i fill (#23): se
|
||||
# questa lettura solleva, i fill sono gia' salvi (sopra), e non e' un guasto del
|
||||
# giro (isolamento) -> si segnala e si continua (disaster-SL sotto, comunque).
|
||||
try:
|
||||
newpos = trader.position_usd(inst)
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
|
||||
print(f" reconcile: pos illeggibile ({type(e).__name__})")
|
||||
if do_execute:
|
||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||
stato = ds.get("state")
|
||||
@@ -308,6 +363,15 @@ def _run():
|
||||
"amount": round(ds.get("amount") or 0, 4),
|
||||
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
|
||||
"notes": str(ds.get("notes"))[:300]})
|
||||
elif stato == "unknown":
|
||||
# (#24) posizione o open_orders illeggibili: NON e' "naked" (non si e' tolto
|
||||
# nulla, ensure_disaster_sl non cancella ne' piazza su dati a meta'). E' un
|
||||
# giro degradato lo stesso: non si sa se la protezione esistente e' ancora
|
||||
# coerente con la posizione attuale.
|
||||
non_verificati.append(asset)
|
||||
print(f" ⚠️ {asset}: protezione NON VERIFICATA — {ds.get('notes')}")
|
||||
notify("⚠️ BOOK — disaster-SL non verificabile (lettura posizione/ordini fallita)",
|
||||
{"asset": asset, "notes": str(ds.get("notes"))[:300]})
|
||||
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
|
||||
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
|
||||
falliti.append(asset)
|
||||
@@ -327,16 +391,27 @@ def _run():
|
||||
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
|
||||
if falliti:
|
||||
print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}")
|
||||
if non_verificati:
|
||||
print(f" ⚠️ disaster-SL NON VERIFICATO (lettura posizione/ordini fallita): {', '.join(non_verificati)}")
|
||||
if not do_execute:
|
||||
print(" => DRY-RUN: nessun ordine inviato." +
|
||||
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
||||
elif solo_protezione:
|
||||
# (#21, #22) non e' "Nessuna azione: conto gia' al target" — il conto potrebbe NON esserci
|
||||
# per niente al target, semplicemente non lo si sa. Distinto dal ramo HOLD sotto.
|
||||
print(f" => SOLO PROTEZIONE: nessun ordine di ribilancio ({motivo_breve}), disaster-SL controllato.")
|
||||
elif all(x.startswith("HOLD") for x in actions):
|
||||
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||
else:
|
||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
|
||||
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
|
||||
if scoperti or falliti:
|
||||
# (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha
|
||||
# ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se
|
||||
# il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima.
|
||||
# (#24) `non_verificati` e' degradato allo stesso modo: la protezione esistente non e' stata
|
||||
# controllata, indipendentemente da solo_protezione.
|
||||
if scoperti or falliti or non_verificati or (solo_protezione and do_execute):
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
|
||||
@@ -245,12 +245,20 @@ def main() -> int:
|
||||
else:
|
||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||
|
||||
if r["changes"] or r["level"] == "AZIONE":
|
||||
notify("💸 FEE WATCH — schema fee Deribit", {
|
||||
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
|
||||
# il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
|
||||
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
|
||||
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
|
||||
inviato = True
|
||||
if serve_invio:
|
||||
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
|
||||
"verdetto": f"[{r['level']}] {r['reason']}",
|
||||
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
||||
})
|
||||
save_state(r["current"])
|
||||
if not inviato and (not quiet or interessante):
|
||||
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
|
||||
if not serve_invio or inviato:
|
||||
save_state(r["current"])
|
||||
registra_giro(RUNS, level=r["level"])
|
||||
return 2 if r["level"] == "AZIONE" else 0
|
||||
|
||||
|
||||
@@ -125,7 +125,9 @@ def report() -> None:
|
||||
if ap:
|
||||
print(" posizioni aperte :")
|
||||
for a, d in sorted(ap.items()):
|
||||
print(f" {a} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} (dal {d['dal'][:16]})")
|
||||
marca = "" if d.get("direzione", "long") == "long" else " SHORT" # #32 C2
|
||||
print(f" {a}{marca} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} "
|
||||
f"(dal {d['dal'][:16]})")
|
||||
else:
|
||||
print(" posizioni aperte : nessuna (flat)")
|
||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||
|
||||
@@ -93,6 +93,59 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
|
||||
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
||||
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
||||
|
||||
# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script
|
||||
# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca).
|
||||
LABEL_PREFIX = "usde-quota-"
|
||||
|
||||
|
||||
def _cancella(c, order_id: str) -> tuple[bool, str]:
|
||||
"""Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la
|
||||
lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche'
|
||||
il chiamante deve poter fermarsi con un messaggio invece di un traceback."""
|
||||
try:
|
||||
resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
||||
except Exception as e: # noqa: BLE001 — si registra (P3)
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error":
|
||||
return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp)
|
||||
return True, ""
|
||||
|
||||
|
||||
def cancella_residui(c) -> list[str]:
|
||||
"""issue #26 — GTC mai cancellati. Il gateway cerbero-mcp NON accetta `time_in_force`
|
||||
(PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit
|
||||
riempito solo in parte resta APERTO per sempre e si accumula a ogni giro invece di sparire
|
||||
con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label
|
||||
(`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca.
|
||||
|
||||
-> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non
|
||||
si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio)."""
|
||||
# non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC (quotazione USDC o
|
||||
# base USDE): si interrogano entrambe e si unisce per order_id, cosi' non si perde il residuo
|
||||
# se il venue lo elenca sotto la base.
|
||||
visti = {}
|
||||
for valuta in ("USDC", "USDE"):
|
||||
out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders",
|
||||
{"currency": valuta, "kind": "spot", "type": "all"}))
|
||||
ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in ordini:
|
||||
if isinstance(o, dict) and o.get("order_id") is not None:
|
||||
visti[o["order_id"]] = o
|
||||
ordini = list(visti.values())
|
||||
cancellati = []
|
||||
for o in ordini:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
instrument = o.get("instrument") or o.get("instrument_name")
|
||||
label = o.get("label") or ""
|
||||
if instrument == U.SPOT and label.startswith(LABEL_PREFIX):
|
||||
oid = o.get("order_id")
|
||||
ok, motivo = _cancella(c, oid)
|
||||
if not ok:
|
||||
raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}")
|
||||
cancellati.append(oid)
|
||||
return cancellati
|
||||
|
||||
|
||||
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
|
||||
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
||||
@@ -232,6 +285,9 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
residui = cancella_residui(c)
|
||||
print(f" residui cancellati all'avvio: {len(residui)}"
|
||||
+ (f" ({', '.join(residui)})" if residui else ""))
|
||||
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
||||
for _ in range(60):
|
||||
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
||||
@@ -274,6 +330,18 @@ def main() -> int:
|
||||
"amount": amt, "filled": filled})
|
||||
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
||||
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
||||
# issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non
|
||||
# accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si
|
||||
# FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo.
|
||||
if filled < amt:
|
||||
oid = order.get("order_id")
|
||||
ok, motivo = _cancella(c, oid)
|
||||
fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}"
|
||||
if not ok:
|
||||
print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}"
|
||||
" — mi fermo (mai un secondo ordine con un residuo vivo)")
|
||||
break
|
||||
print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})")
|
||||
if filled <= 0:
|
||||
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
||||
time.sleep(a.pausa)
|
||||
|
||||
+51
-50
@@ -58,12 +58,6 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
|
||||
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
||||
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
||||
|
||||
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
|
||||
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
|
||||
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
|
||||
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
|
||||
# ogni giorno finche' la riga non viene tolta.
|
||||
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
|
||||
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
||||
|
||||
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
|
||||
@@ -162,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
|
||||
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
||||
|
||||
|
||||
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
|
||||
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
|
||||
if verdetto_corrente != "IDONEO":
|
||||
return False
|
||||
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
|
||||
|
||||
|
||||
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||
"""PURA. Condizioni di allerta attive su un record."""
|
||||
if not rec:
|
||||
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||
return att
|
||||
|
||||
|
||||
def nuove_condizioni(att_now: set[str], att_prev: set[str], prev: dict | None) -> set[str]:
|
||||
"""PURA (issue #29 B2). Le condizioni da notificare in questo giro.
|
||||
|
||||
Fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si leggeva: un
|
||||
invio fallito restava "gia' detto" per l'episodio intero (l'ora dopo att_prev diceva ancora
|
||||
che la condizione c'era, quindi non era piu' "nuova"). Schema di cuscino_watch.py: una
|
||||
condizione conta come "gia' detta" solo se era attiva l'ultima volta E il suo invio e'
|
||||
RIUSCITO — cioe' compare in `allertati` del record precedente. Un record vecchio senza
|
||||
`allertati` (scritto prima di questo fix) vale come "tutto allertato": nessun rispam
|
||||
retroattivo delle condizioni gia' viste."""
|
||||
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||
return att_now - (att_prev & confermate_prev)
|
||||
|
||||
|
||||
def _safe_client():
|
||||
try:
|
||||
from src.live.deribit import DeribitRead
|
||||
@@ -284,54 +285,58 @@ def main() -> int:
|
||||
v, v_motivo = verdetto(records + [rec], now)
|
||||
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
||||
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with STATE.open("a") as fh:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
|
||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
|
||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
|
||||
# issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
|
||||
# non si leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema di
|
||||
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
|
||||
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
|
||||
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
||||
nuove = att_now - att_prev
|
||||
nuove = nuove_condizioni(att_now, att_prev, prev)
|
||||
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||
inviati = []
|
||||
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
|
||||
if "DEPEG_CRIT" in att_now:
|
||||
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||
ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||
inviati.append("DEPEG_CRIT")
|
||||
if ok:
|
||||
allertati.add("DEPEG_CRIT")
|
||||
if "DEPEG_WARN" in nuove:
|
||||
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||
"nota": "entra gia' nel sizing orario del book"})
|
||||
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||
"nota": "entra gia' nel sizing orario del book"})
|
||||
inviati.append("DEPEG_WARN")
|
||||
if ok:
|
||||
allertati.add("DEPEG_WARN")
|
||||
if "QUOTA_OVER" in nuove:
|
||||
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||
ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||
inviati.append("QUOTA_OVER")
|
||||
if ok:
|
||||
allertati.add("QUOTA_OVER")
|
||||
if "BLIND" in nuove:
|
||||
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||
inviati.append("BLIND")
|
||||
if ok:
|
||||
allertati.add("BLIND")
|
||||
rec["allertati"] = sorted(allertati)
|
||||
|
||||
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
||||
if v != v_prev:
|
||||
if v == "IDONEO":
|
||||
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
||||
"prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"})
|
||||
"prossimo passo": f"decisione di quota dell'operatore (tetto allerta {c['quota_max_frac']:.0%})"})
|
||||
elif v == "NON_IDONEO":
|
||||
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
||||
inviati.append(f"VERDETTO->{v}")
|
||||
|
||||
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
|
||||
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
|
||||
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
|
||||
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
|
||||
rend = rendimento(records + [rec])
|
||||
if quota_da_decidere(now, v):
|
||||
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
||||
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
||||
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
||||
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
|
||||
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
|
||||
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
|
||||
inviati.append("QUOTA_DA_DECIDERE")
|
||||
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with STATE.open("a") as fh:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
|
||||
cambiato = bool(inviati) or v != v_prev
|
||||
if not quiet or cambiato:
|
||||
@@ -344,14 +349,12 @@ def main() -> int:
|
||||
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
||||
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
||||
if quota is not None:
|
||||
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
|
||||
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
|
||||
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
|
||||
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
|
||||
# Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
|
||||
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
|
||||
# NON e' piu' il Segregated Standard Margin di prima di quella data.
|
||||
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
||||
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
||||
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
|
||||
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
|
||||
print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
|
||||
f" {c['haircut']:.0%})")
|
||||
if an["delta"] is not None:
|
||||
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
||||
@@ -366,8 +369,6 @@ def main() -> int:
|
||||
f" (tot {rend['tot']:+.6f} USDE)")
|
||||
if rend["finestre"] < 4:
|
||||
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
||||
if v == "IDONEO" and not quota_da_decidere(now, v):
|
||||
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
|
||||
if inviati:
|
||||
print(f" allarmi : {', '.join(inviati)}")
|
||||
return 0
|
||||
|
||||
@@ -31,6 +31,8 @@ def _titolo(rep: dict) -> str:
|
||||
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
|
||||
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
|
||||
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
|
||||
if rep.get("severity") == "blind":
|
||||
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
|
||||
if rep.get("severity") == "warn":
|
||||
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
||||
|
||||
+26
-9
@@ -236,11 +236,22 @@ class DeribitRead:
|
||||
return out.get("positions", []) if isinstance(out, dict) else []
|
||||
|
||||
def position_usd(self, instrument: str) -> float:
|
||||
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat."""
|
||||
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.
|
||||
|
||||
⚠️ Il gateway manda `size`/`size_currency` in ASSOLUTO e il segno a parte in `direction`
|
||||
(#17): letta com'era, uno short risultava un long, e close()/ensure_disaster_sl decidevano
|
||||
lato ordine e prezzo dello stop dal segno sbagliato. `direction` assente o `"zero"` -> ci si
|
||||
fida di `size` cosi' com'e' (compatibile con un gateway che un giorno mandasse gia' il segno)."""
|
||||
cur = _CONTRACT[instrument]["settle"]
|
||||
for p in self.positions(cur):
|
||||
if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
|
||||
return float(p.get("size") or p.get("size_currency") or 0.0)
|
||||
s = float(p.get("size") or p.get("size_currency") or 0.0)
|
||||
direction = str(p.get("direction") or "").lower()
|
||||
if direction in ("sell", "short"):
|
||||
return -abs(s)
|
||||
if direction in ("buy", "long"):
|
||||
return abs(s)
|
||||
return s
|
||||
return 0.0
|
||||
|
||||
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
|
||||
@@ -251,17 +262,23 @@ class DeribitRead:
|
||||
|
||||
def open_orders(self, instrument: str) -> list[dict]:
|
||||
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger
|
||||
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id."""
|
||||
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.
|
||||
|
||||
⚠️ Se UNA delle due chiamate solleva, NON si ingoia (#24): prima ritornava [] anche a meta'
|
||||
letta, e un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione
|
||||
persa su 'all' — il chiamante (ensure_disaster_sl) deve saperlo, non decidere su dati a
|
||||
meta'. Il chiamante che vuole restare in piedi comunque (shadow.shadow_report) si mette il
|
||||
try/except da solo."""
|
||||
cur = _CONTRACT[instrument]["settle"]
|
||||
seen: dict = {}
|
||||
for typ in ("all", "trigger_all"):
|
||||
try:
|
||||
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
|
||||
{"currency": cur, "type": typ}))
|
||||
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in lst:
|
||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||
seen[o.get("order_id")] = o
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
|
||||
raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
|
||||
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in lst:
|
||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||
seen[o.get("order_id")] = o
|
||||
return list(seen.values())
|
||||
|
||||
+98
-39
@@ -4,12 +4,17 @@ Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA
|
||||
(Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee
|
||||
reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only.
|
||||
|
||||
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si
|
||||
tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione).
|
||||
Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
|
||||
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
|
||||
per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
|
||||
DEFAULT per chi non lo chiama (il micro-test, size fissa minima). Le CHIUSURE si tentano SEMPRE
|
||||
senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). Nessun
|
||||
parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
|
||||
|
||||
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py).
|
||||
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente.
|
||||
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
|
||||
2026-06-23 (commit db738bc): questo NON e' un percorso di micro-test, e' l'esecuzione di produzione
|
||||
usata da `scripts/live/book_execute.py` (cron del book). Il micro-test
|
||||
(scripts/live/microtest.py) resta un percorso a parte, sotto lo stesso guardrail, col cap di
|
||||
DEFAULT (MAX_AMOUNT).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,11 +22,13 @@ from dataclasses import dataclass
|
||||
|
||||
from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price
|
||||
|
||||
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger
|
||||
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello
|
||||
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima.
|
||||
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
|
||||
# DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il
|
||||
# micro-test, che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva
|
||||
# set_open_cap() dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop
|
||||
# sopra il sizing di TP01/book, non il sizing operativo.
|
||||
ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"}
|
||||
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25}
|
||||
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap()
|
||||
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
|
||||
|
||||
|
||||
@@ -29,6 +36,19 @@ class GuardrailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RebalanceParziale(RuntimeError):
|
||||
"""`rebalance_signed` ha eseguito almeno un fill (es. il close di un flip) e POI un passo
|
||||
successivo ha sollevato (es. l'open dall'altro lato). Il fill gia' eseguito NON e' un fill
|
||||
perso: `.fills` li porta al chiamante (che li registra) e `.causa` e' l'eccezione originale
|
||||
(il chiamante la rilancia dentro il suo handler di isolamento, #23)."""
|
||||
|
||||
def __init__(self, fills: list["Fill"], causa: Exception):
|
||||
self.fills = fills
|
||||
self.causa = causa
|
||||
super().__init__(f"rebalance parziale: {len(fills)} fill eseguiti, poi "
|
||||
f"{type(causa).__name__}: {causa}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fill:
|
||||
"""Esito verificato di un ordine reale."""
|
||||
@@ -55,6 +75,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
|
||||
class DeribitTrader(DeribitRead):
|
||||
"""Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._open_cap: dict[str, float] = {} # per-strumento, da set_open_cap() (#18)
|
||||
|
||||
def set_open_cap(self, instrument: str, usd_cap: float, mark: float) -> None:
|
||||
"""Tetto di apertura per `instrument`, derivato dal cap di config del libro (#18):
|
||||
amount_max = 1,10 * usd_cap / mark — 10% sopra il cap nozionale per non rifiutare
|
||||
un'apertura legittima per un arrotondamento di sizing/mark. Backstop anti-fat-finger
|
||||
sopra il sizing di TP01/book, non il sizing operativo (quello resta config/live.json).
|
||||
Senza chiamata, `_submit` resta sul DEFAULT MAX_AMOUNT (il micro-test)."""
|
||||
if usd_cap <= 0 or mark <= 0:
|
||||
raise ValueError(f"set_open_cap richiede usd_cap>0 e mark>0 (usd_cap={usd_cap}, mark={mark})")
|
||||
self._open_cap[instrument] = 1.10 * usd_cap / mark
|
||||
|
||||
def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool,
|
||||
label: str, order_type: str = "market", price: float | None = None) -> Fill:
|
||||
if instrument not in ALLOWED:
|
||||
@@ -62,7 +96,9 @@ class DeribitTrader(DeribitRead):
|
||||
if side not in ("buy", "sell"):
|
||||
raise GuardrailError(f"side non valido: {side}")
|
||||
if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre
|
||||
cap = MAX_AMOUNT.get(instrument, 0.0)
|
||||
# getattr, non self._open_cap diretto: un DeribitTrader creato con object.__new__
|
||||
# (test che saltano __init__) non ha l'attributo, e qui non deve dare AttributeError.
|
||||
cap = getattr(self, "_open_cap", {}).get(instrument, MAX_AMOUNT[instrument])
|
||||
if amount <= 0 or amount > cap:
|
||||
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
|
||||
if amount <= 0:
|
||||
@@ -150,36 +186,46 @@ class DeribitTrader(DeribitRead):
|
||||
- target ~0 -> close();
|
||||
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
||||
Ritorna i Fill eseguiti."""
|
||||
Ritorna i Fill eseguiti.
|
||||
|
||||
⚠️ FILL PERSI (#23). Un flip e' DUE passi (close, poi open dall'altra parte): se il primo
|
||||
va a segno e il secondo solleva, il fill del primo passo NON deve sparire. Da qui in poi
|
||||
un'eccezione con almeno un fill gia' in `fills` diventa `RebalanceParziale(fills, causa)`
|
||||
invece di propagare nuda: il chiamante registra i fill fatti e poi rilancia `causa`."""
|
||||
cur = self.position_usd(instrument)
|
||||
if abs(target_notional_usd - cur) < min_usd:
|
||||
return []
|
||||
fills: list[Fill] = []
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
try:
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
if f:
|
||||
fills.append(f)
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
|
||||
if fills:
|
||||
raise RebalanceParziale(fills, e) from e
|
||||
raise
|
||||
|
||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||
@@ -194,10 +240,20 @@ class DeribitTrader(DeribitRead):
|
||||
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
||||
|
||||
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
|
||||
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente):
|
||||
- flat -> cancella eventuali bracket orfani;
|
||||
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione;
|
||||
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
|
||||
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
|
||||
|
||||
Stati possibili (`dict["state"]`):
|
||||
- `"flat"` -> nessuna posizione: cancella eventuali bracket orfani;
|
||||
- `"ok"` -> gia' coerente (1 bracket, amount~=, stop entro 5%): lascia com'e';
|
||||
- `"placed"` -> ricostruito (cancellato l'incoerente e ripiazzato) al primo o secondo
|
||||
tentativo, e il piazzamento e' verificato;
|
||||
- `"place-failed"` -> il ripiazzamento e' partito ma NON e' verificato (state/trade non
|
||||
confermano): l'ordine potrebbe non essere a book;
|
||||
- `"naked"` -> il bracket incoerente e' stato cancellato e il ripiazzamento e'
|
||||
fallito (eccezione) 2 volte di fila: la posizione e' SENZA stop;
|
||||
- `"unknown"` -> posizione o open_orders illeggibili (#24): NON cancella ne' piazza —
|
||||
decidere alla cieca su meta' dei dati e' peggio che aspettare il giro
|
||||
dopo. `notes` porta tipo ed errore della lettura fallita.
|
||||
|
||||
⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima
|
||||
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
|
||||
@@ -216,8 +272,11 @@ class DeribitTrader(DeribitRead):
|
||||
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
|
||||
misurarlo. Riparato il silenzio, non toccata la sequenza.
|
||||
"""
|
||||
pos = self.position_usd(instrument)
|
||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
||||
try:
|
||||
pos = self.position_usd(instrument)
|
||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
||||
except Exception as e: # noqa: BLE001 — si registra il motivo (P3); non si cancella ne' piazza (#24)
|
||||
return {"state": "unknown", "notes": f"{type(e).__name__}: {e}"}
|
||||
if abs(pos) < FLAT_USD:
|
||||
for o in brackets:
|
||||
self.cancel_order(o.get("order_id"))
|
||||
|
||||
+49
-13
@@ -248,12 +248,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
||||
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
|
||||
+ (f": {d['nota']}" if d["nota"] else "")
|
||||
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None)
|
||||
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
||||
# (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
|
||||
# cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
|
||||
# intervallo troverebbe il primo (gia' "dichiarato") e lo SOVRASCRIVEREBBE, perdendo
|
||||
# il primo importo invece di somarli.
|
||||
rilevato = next((e for e in eventi
|
||||
if e["ts_dopo"] == cur["ts_utc"] and e["fonte"] == "rilevato"), None)
|
||||
if rilevato is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
rilevato.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
||||
classe="movimento", fonte="dichiarato+rilevato",
|
||||
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
||||
continue
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]
|
||||
and e["fonte"] in ("dichiarato", "dichiarato+rilevato")), None)
|
||||
if gia is not None: # un altro dichiarato nello stesso intervallo: si SOMMA
|
||||
nuovo_delta = gia["delta"] + d["delta"]
|
||||
gia.update(delta=nuovo_delta,
|
||||
pct=(nuovo_delta / float(gia["prima"]) if gia["prima"] else None),
|
||||
dopo=float(gia["prima"]) + nuovo_delta,
|
||||
dichiarazione=(gia["dichiarazione"] + "; " + nota_d))
|
||||
continue
|
||||
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
||||
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
||||
delta=d["delta"],
|
||||
@@ -376,16 +390,23 @@ def pnl_giorno(con, giorno: date) -> dict:
|
||||
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
|
||||
|
||||
|
||||
MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9)
|
||||
|
||||
|
||||
def salute(con, giorno: date) -> dict:
|
||||
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
|
||||
|
||||
Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un
|
||||
allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero.
|
||||
|
||||
Il giro dell'ora CORRENTE scatta al minuto `MINUTO_GIRO` (il cron e' `47 * * * *`): prima
|
||||
di quel minuto non e' ancora avvenuto, e contarlo comunque (#35 C9) segnalava un giro
|
||||
"mancante" che semplicemente non era ancora il suo turno.
|
||||
"""
|
||||
lb = stato_libro(con, giorno)
|
||||
adesso = datetime.now(timezone.utc)
|
||||
in_corso = giorno == adesso.date()
|
||||
atteso = (adesso.hour + 1) if in_corso else 24
|
||||
atteso = (adesso.hour + (1 if adesso.minute >= MINUTO_GIRO + 1 else 0)) if in_corso else 24
|
||||
fatti = lb.get("giri", 0)
|
||||
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
|
||||
giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti))
|
||||
@@ -591,16 +612,19 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
# il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento
|
||||
# diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto)
|
||||
mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"]
|
||||
g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat()
|
||||
tot = e1 - e0 - sum(e["delta"] for e in mv_ev)
|
||||
picco = max(r["equity"] for r in eq)
|
||||
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s]
|
||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s)
|
||||
# (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
|
||||
# prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
|
||||
# data (>= giorno-7) include anche le ore del giorno-7 gia' trascorse, cioe' 8 giorni di
|
||||
# calendario, non 7.
|
||||
g7_cutoff = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() + "T23:59:59+00:00"
|
||||
base7 = next((r for r in reversed(eq) if r["ts_utc"] <= g7_cutoff), None)
|
||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"] > g7_cutoff)
|
||||
# Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica
|
||||
# vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione.
|
||||
rilevante = abs(tot) >= max(5.0, 0.01 * e1)
|
||||
if rilevante and g7:
|
||||
quota = (e1 - g7[0]["equity"] - mv7) / tot
|
||||
if rilevante and base7 is not None:
|
||||
quota = (e1 - base7["equity"] - mv7) / tot
|
||||
if quota > 0.60:
|
||||
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
||||
"e' un evento.")
|
||||
@@ -609,10 +633,22 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
|
||||
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
|
||||
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
|
||||
if picco > 0:
|
||||
dd = (e1 - picco) / picco
|
||||
# (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
|
||||
# TWR scorpora): sulla serie grezza un prelievo si legge come drawdown di trading e un
|
||||
# versamento alza il picco, mascherando un DD vero appena prima del versamento.
|
||||
mv_ord = sorted(mv_ev, key=lambda e: e["ts_dopo"])
|
||||
cum_mov, i_mov, eq_netto = 0.0, 0, []
|
||||
for r in eq:
|
||||
while i_mov < len(mv_ord) and r["ts_utc"] >= mv_ord[i_mov]["ts_dopo"]:
|
||||
cum_mov += mv_ord[i_mov]["delta"]
|
||||
i_mov += 1
|
||||
eq_netto.append(r["equity"] - cum_mov)
|
||||
picco_netto = max(eq_netto)
|
||||
if picco_netto > 0:
|
||||
dd = (eq_netto[-1] - picco_netto) / picco_netto
|
||||
if dd < -0.005:
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). "
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco_netto:,.2f}, "
|
||||
"al netto dei movimenti di capitale certi). "
|
||||
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
|
||||
|
||||
# --- volatilita' implicita contro realizzata ---------------------------------------------
|
||||
|
||||
+20
-7
@@ -95,15 +95,20 @@ def _positions(client):
|
||||
USDE_INDEX_URL = _usde.index_url()
|
||||
|
||||
|
||||
def _collaterale_usde(client) -> tuple[float, str]:
|
||||
def _collaterale_usde(client) -> tuple[float | None, str]:
|
||||
"""Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che
|
||||
PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in
|
||||
src/live/usde.py — difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC,
|
||||
-24% fantasma dopo la prima conversione."""
|
||||
-24% fantasma dopo la prima conversione.
|
||||
|
||||
⚠️ (#20) Un'eccezione sulla LETTURA del conto USDE non e' "0 USDE": e' "non lo so". Ritorna
|
||||
None (non 0.0) + la nota dell'eccezione -> `_equity` propaga l'ignoto invece di dedurre un
|
||||
equity piu' basso di quello vero. Eq letta con successo e <= 0 resta 0.0 (conto senza USDE:
|
||||
quello e' un fatto accertato, legittimo)."""
|
||||
try:
|
||||
eq = float(client.account_summary("USDE").get("equity") or 0)
|
||||
except Exception:
|
||||
return 0.0, ""
|
||||
except Exception as e:
|
||||
return None, f"USDE illeggibile: {type(e).__name__}: {e}"
|
||||
if eq <= 0:
|
||||
return 0.0, ""
|
||||
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL)
|
||||
@@ -117,6 +122,8 @@ def _equity(client, marks):
|
||||
eq = float(client.account_summary("USDC").get("equity") or 0)
|
||||
if eq > 1:
|
||||
usde_usd, usde_note = _collaterale_usde(client)
|
||||
if usde_usd is None: # (#20) USDE illeggibile -> l'equity e' IGNOTA,
|
||||
return None, usde_note # non "USDC senza USDE": non si assume 0.
|
||||
if usde_usd > 0:
|
||||
return eq + usde_usd, f"mainnet USDC + {usde_note}"
|
||||
return eq, "mainnet USDC"
|
||||
@@ -133,7 +140,8 @@ def _equity(client, marks):
|
||||
if any_ok and tot > 1:
|
||||
return tot, "mainnet coin-margined"
|
||||
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
|
||||
# segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside).
|
||||
# segnalato a book_execute, che in modo SOLO PROTEZIONE non manda alcun rebalance_signed —
|
||||
# controlla comunque il disaster-SL, che dipende da posizione/mark, non da equity (#21).
|
||||
return None, "conto flat / non finanziato"
|
||||
|
||||
|
||||
@@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
eq_basis = ("override" if equity_override is not None
|
||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
||||
# Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del
|
||||
# conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO.
|
||||
# conto reale. SEGNALATO, e book_execute passa a SOLO PROTEZIONE: nessun rebalance_signed (non
|
||||
# si dimensiona un ordine senza sapere quanto vale il conto), ma il disaster-SL si controlla
|
||||
# comunque per ogni asset (#21).
|
||||
eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} "
|
||||
f"invece del conto reale") if (equity_override is None and client is not None
|
||||
and not real_eq) else None
|
||||
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
|
||||
return dict(
|
||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
||||
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
||||
# (#25) TUTTI i mark devono venire da mainnet, non solo BTC: con due asset, un solo
|
||||
# controllo lasciava ETH libero di essere in fallback (close certificata) senza che
|
||||
# `online` lo dicesse -> il gate offline di book_execute non si accorgeva del guasto.
|
||||
online=(client is not None and all(marks_src.get(a) == "mainnet" for a in ASSETS)),
|
||||
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
||||
assets=assets, orders=orders, live_trades=live_trades,
|
||||
|
||||
+45
-25
@@ -262,31 +262,43 @@ def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0.
|
||||
|
||||
|
||||
def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]:
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty."""
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty.
|
||||
|
||||
Lotti CON SEGNO (#32 C2): ogni lotto e' long (+1) o short (-1). Un fill chiude PRIMA i
|
||||
lotti di segno OPPOSTO (FIFO), e l'eccedenza — se resta qty dopo aver chiuso tutto il
|
||||
lato opposto — apre (o accoda) un lotto nel segno del fill stesso. Prima di questa
|
||||
riparazione un SELL senza lotti long da chiudere spariva in silenzio (nessun lotto,
|
||||
nessun round-trip): l'inversione long -> short in un solo fill e' lo stesso meccanismo,
|
||||
solo che il lato opposto si esaurisce a meta' fill invece che all'inizio.
|
||||
"""
|
||||
from collections import deque, defaultdict
|
||||
lotti: dict[str, deque] = defaultdict(deque)
|
||||
lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno]
|
||||
rts: list[dict] = []
|
||||
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
|
||||
fee_u = f.fee / f.qty if f.qty else 0.0
|
||||
if f.side == "buy":
|
||||
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
|
||||
else:
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and lotti[f.asset]:
|
||||
lq, lp, lts, lfee_u = lotti[f.asset][0]
|
||||
usa = min(resto, lq)
|
||||
lordo = usa * (f.price - lp)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
lotti[f.asset].popleft()
|
||||
else:
|
||||
lotti[f.asset][0][0] = lq
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
|
||||
segno_fill = 1 if f.side == "buy" else -1
|
||||
dq = lotti[f.asset]
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
|
||||
lq, lp, lts, lfee_u, lseg = dq[0]
|
||||
usa = min(resto, lq)
|
||||
# long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
|
||||
lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
|
||||
direzione=("long" if lseg == 1 else "short")))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
dq.popleft()
|
||||
else:
|
||||
dq[0][0] = lq
|
||||
if resto > 1e-12: # l'eccedenza apre/accoda nel segno del fill
|
||||
dq.append([resto, f.price, f.ts_utc, fee_u, segno_fill])
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t, direzione=("long" if s == 1 else "short"))
|
||||
for q, p, t, _, s in dq] for a, dq in lotti.items() if dq}
|
||||
return rts, aperti
|
||||
|
||||
|
||||
@@ -332,8 +344,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]])
|
||||
|
||||
|
||||
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
|
||||
|
||||
`qty > 0` (#32 C10): un fill "NON VERIFICATO" da `place_order error` puo' arrivare a
|
||||
qty 0 — non e' un trade, resta nel DB marcato (`verified=0`) ma fuori dal FIFO.
|
||||
"""
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
rts, _ = fifo_roundtrips(fills)
|
||||
@@ -358,7 +374,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None):
|
||||
|
||||
|
||||
def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
# qty > 0 (#32 C10): stessa esclusione di rebuild_roundtrips, un fill a qty 0 non e' posizione.
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
_, aperti = fifo_roundtrips(fills)
|
||||
@@ -366,7 +383,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
for a, lotti in aperti.items():
|
||||
q = sum(x["qty"] for x in lotti)
|
||||
costo = sum(x["qty"] * x["price"] for x in lotti)
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti))
|
||||
# un asset ha un solo segno di lotti aperti alla volta (fifo_roundtrips chiude prima
|
||||
# il lato opposto): la direzione del primo lotto vale per tutti (#32 C2).
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti),
|
||||
direzione=lotti[0]["direzione"])
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -346,6 +346,11 @@ def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
|
||||
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
||||
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
||||
report["severity"] = "alert"
|
||||
# issue #31: senza ALERT, un asset BLIND lasciava severity=None — il titolo del runner
|
||||
# cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per un episodio che e'
|
||||
# "non vedo", non "stress misurato". BLIND e' lo stato MORBIDO: cede il passo ad ALERT.
|
||||
elif report["levels"] and any(l == "BLIND" for l in report["levels"].values()):
|
||||
report["severity"] = "blind"
|
||||
|
||||
# --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati
|
||||
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
|
||||
|
||||
@@ -210,6 +210,21 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
|
||||
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
|
||||
|
||||
|
||||
def test_secco_stampa_e_non_salva(db, monkeypatch, tmp_path, capsys):
|
||||
"""#34: prima del fix `J.salva()` veniva chiamato comunque, prima del controllo su
|
||||
`secco` — un giro "a secco" scriveva lo stesso le sezioni misurate nel DB."""
|
||||
import analista as CLI
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi che non deve finire nel DB.", None))
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
g = date(2026, 8, 21)
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB vuoto in partenza
|
||||
stato = CLI.scrivi(db, g, modello="finto", secco=True, verbose=False, telegram=False)
|
||||
assert stato == "ok"
|
||||
capsys.readouterr() # non e' l'oggetto del test
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB ANCORA vuoto
|
||||
assert not (tmp_path / f"{g}.md").exists() # scrivi_file non chiamato
|
||||
|
||||
|
||||
def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path):
|
||||
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
|
||||
import analista as CLI
|
||||
|
||||
+44
-13
@@ -415,7 +415,10 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
|
||||
|
||||
|
||||
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
|
||||
"""Se il report porta skh_error, _run() lo stampa SEMPRE (anche in dry-run); quando il giro e'
|
||||
ARMATO l'alert parte UNA sola volta, nel modo SOLO PROTEZIONE (#22) — niente flat silenzioso,
|
||||
ma nemmeno due notify per lo stesso guasto (il vecchio notify incondizionato e' stato tolto,
|
||||
U6 #22)."""
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
@@ -426,17 +429,27 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
|
||||
orders=[],
|
||||
)
|
||||
|
||||
class _TraderOk:
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
|
||||
mod._run()
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "SKH FEED ERRORE" in out # stampato nel log
|
||||
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
|
||||
assert "SKH FEED ERRORE" in out # stampato nel log, sempre
|
||||
assert len(alerts) == 1 # UNA sola notify, non due
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -535,8 +548,11 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
|
||||
assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP"
|
||||
|
||||
|
||||
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
|
||||
def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
|
||||
"""(#21, U2) Comportamento cambiato: eq_fallback NON fa piu' proseguire un rebalance sul
|
||||
paper_cap — passa a SOLO PROTEZIONE (nessun ordine, disaster-SL comunque controllato, exit 2).
|
||||
Prima di questa riparazione il giro dimensionava un ordine reale su un capitale fittizio da
|
||||
$2.000 quando non si sapeva quanto valesse il conto vero; ora non manda alcun ordine."""
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
@@ -551,18 +567,33 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
alerts = []
|
||||
|
||||
class FakeDT:
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
|
||||
def position_usd(self, inst): return 0.0
|
||||
|
||||
trader = FakeDT()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda: FakeDT())
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
assert "EQUITY FALLBACK" in out # avvisato
|
||||
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
|
||||
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)
|
||||
assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta)
|
||||
# (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)"
|
||||
# e' stato tolto perche' diceva il falso (qui non si dimensiona alcun ordine) — resta solo
|
||||
# quello del modo SOLO PROTEZIONE.
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato
|
||||
assert uscita == 2 # giro degradato
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Test U2 (gitea:Adriano/PythagorasGoal #20, #21, #22, #23, #25).
|
||||
|
||||
Principio unico: quando il giro non sa QUANTO vale il conto (#20, #21) o QUALE e' il segnale
|
||||
(#22), NON manda ordini di ribilancio, ma CONTROLLA LO STESSO il disaster-SL di ogni asset — la
|
||||
protezione dipende da posizione e mark, non da equity o segnale. E un fill gia' eseguito non e'
|
||||
un fill perso se un passo successivo del rebalance, o la lettura della posizione dopo, falliscono
|
||||
(#23). `online` richiede TUTTI i mark da mainnet, non solo BTC (#25).
|
||||
|
||||
Nessun test tocca la rete: trader ed eccezioni sono finti.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from helpers import carica_script
|
||||
from src.live import shadow as S
|
||||
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, RebalanceParziale
|
||||
|
||||
|
||||
def _fresh_bar() -> str:
|
||||
import pandas as _pd
|
||||
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #20 — USDE illeggibile: l'equity diventa IGNOTA (None), non 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _ClientUSDEBoom:
|
||||
def __init__(self, usdc=2056.0):
|
||||
self._usdc = usdc
|
||||
|
||||
def account_summary(self, cur):
|
||||
if cur == "USDE":
|
||||
raise RuntimeError("gateway giu'")
|
||||
return {"equity": self._usdc}
|
||||
|
||||
|
||||
def test_usde_illeggibile_rende_equity_ignota_non_zero():
|
||||
eq, src = S._equity(_ClientUSDEBoom(), {})
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
|
||||
def test_watermark_non_si_scrive_su_equity_ignota(tmp_path, monkeypatch):
|
||||
"""`write_equity_watermark` riceve None quando l'equity e' ignota: non deve scrivere il
|
||||
file (gia' cosi' nel codice esistente — qui lo si blinda con un test, come chiesto)."""
|
||||
import src.live.book as book
|
||||
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
|
||||
assert book.write_equity_watermark(None) is None
|
||||
assert not (tmp_path / "equity_seen.json").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #25 — online richiede TUTTI i mark mainnet, non solo BTC.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_online_richiede_tutti_i_mark_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0},
|
||||
{"BTC": "mainnet", "ETH": "fallback close (RuntimeError)"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
r = S.shadow_report()
|
||||
assert r["online"] is False # prima di #25 sarebbe stato True (guardava solo BTC)
|
||||
|
||||
|
||||
def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0}, {"BTC": "mainnet", "ETH": "mainnet"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
assert S.shadow_report()["online"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #21, #22 — SOLO PROTEZIONE: equity ignota o SKH ignoto -> nessun rebalance_signed, ma il
|
||||
# disaster-SL si controlla LO STESSO per ogni asset. Giro degradato -> exit 2 (se ARMATO).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _asset(nome, inst, mark, order=None):
|
||||
return dict(asset=nome, instrument=inst, tp_frac=1.0, skh_sign=1, skh_state="flat",
|
||||
net_target=225.0, position_usd=0.0, mark=mark, order=order)
|
||||
|
||||
|
||||
def _canned_report(**overrides):
|
||||
base = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
|
||||
eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None,
|
||||
eq_fallback=None,
|
||||
assets=[
|
||||
_asset("BTC", "BTC_USDC-PERPETUAL", 60000.0,
|
||||
order=dict(side="buy", delta=225.0, is_close=False, needs_flip=False)),
|
||||
_asset("ETH", "ETH_USDC-PERPETUAL", 3000.0, order=None),
|
||||
],
|
||||
orders=[],
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class _TraderSoloProtezione:
|
||||
"""Trader finto: registra se rebalance_signed viene chiamato (NON deve) e quali asset
|
||||
ricevono ensure_disaster_sl (TUTTI, comunque)."""
|
||||
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
|
||||
def _run_solo_protezione(canned, monkeypatch, capsys):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
trader = _TraderSoloProtezione()
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
return trader, alerts, uscita, out
|
||||
|
||||
|
||||
def test_eq_fallback_e_solo_protezione(monkeypatch, capsys):
|
||||
canned = _canned_report(
|
||||
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000")
|
||||
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
||||
assert trader.rebalance_chiamato is False # nessun ordine dimensionato
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI
|
||||
# (#21) UNA sola notify per il guasto, non due: il vecchio testo diceva "sizing su paper_cap",
|
||||
# falso in modo SOLO PROTEZIONE (nessun ordine dimensionato affatto).
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert "paper_cap" not in alerts[0][0]
|
||||
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out # riepilogo finale corretto
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_skh_error_e_solo_protezione(monkeypatch, capsys):
|
||||
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
|
||||
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
||||
assert trader.rebalance_chiamato is False
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"]
|
||||
# (#22) UNA sola notify, non due ("SKH feed fallito" + "SKH non calcolato").
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_solo_protezione_in_dry_run_non_esce_2(monkeypatch, capsys):
|
||||
"""Un'anteprima (senza execution_enabled) non ha tentato nulla: non e' un giro degradato."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
canned = _canned_report(eq_fallback="equity reale non leggibile -> sizing su paper_cap")
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=False, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute
|
||||
mod._run() # non deve sollevare
|
||||
assert "solo protezione" in capsys.readouterr().out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #23 — fill persi: un fill gia' eseguito non sparisce se un passo successivo del rebalance
|
||||
# (flip) o la lettura della posizione dopo falliscono.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeExec(DeribitTrader):
|
||||
"""DeribitTrader senza rete: instrumenta close/open per il test del flip parziale."""
|
||||
|
||||
def __init__(self, pos_usd, open_boom=None, chiusura_fill=None):
|
||||
self._pos = pos_usd
|
||||
self._open_boom = open_boom
|
||||
self._chiusura_fill = chiusura_fill
|
||||
|
||||
def position_usd(self, instrument):
|
||||
return self._pos
|
||||
|
||||
def close(self, instrument, label="tp01-close"):
|
||||
return self._chiusura_fill
|
||||
|
||||
def open(self, instrument, side, amount, label="tp01-open"):
|
||||
if self._open_boom is not None:
|
||||
raise self._open_boom
|
||||
return Fill(instrument, side, amount, amount, 60000.0, 0.01, "oid", "filled", True, "")
|
||||
|
||||
|
||||
def test_flip_con_open_che_esplode_dopo_close_solleva_rebalance_parziale():
|
||||
"""execution.py (#23a): il close del flip e' andato, l'open dall'altra parte no -> il fill
|
||||
del close NON deve sparire dentro un'eccezione nuda."""
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
t = _FakeExec(pos_usd=120.0, open_boom=RuntimeError("502 Bad Gateway"), chiusura_fill=close_fill)
|
||||
with pytest.raises(RebalanceParziale) as ei:
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", -80.0, 60000.0, min_usd=5.0)
|
||||
assert ei.value.fills == [close_fill]
|
||||
assert isinstance(ei.value.causa, RuntimeError) and "502" in str(ei.value.causa)
|
||||
|
||||
|
||||
def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): su RebalanceParziale, il fill del close finisce nel jsonl e nella
|
||||
riga di log PRIMA che la causa venga rilanciata; l'asset risulta fallito, l'altro asset
|
||||
(ETH) resta elaborato normalmente."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderFlip()
|
||||
canned = _canned_report() # BTC ha un ordine, ETH order=None
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert uscita == 2
|
||||
assert "-> SELL 0.0020 @ $60,000.0" in out # riga di fill stampata
|
||||
assert "asset falliti in questo giro: BTC" in out # isolamento esistente
|
||||
assert trader.visti_sl == ["ETH_USDC-PERPETUAL"] # BTC salta l'SL questo giro
|
||||
# (il fill del close NON e' un fill perso: e' nel jsonl anche se il resto del flip e' fallito)
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["side"] == "sell" and righe[0]["filled"] == 0.002
|
||||
assert righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): rebalance_signed va a segno, ma `position_usd` (reconcile) solleva
|
||||
dopo -> il fill e' comunque registrato con pos_after None, e non e' un guasto del giro."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderReconcileBoom:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
raise RuntimeError("api 500")
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderReconcileBoom()
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run() # non deve sollevare: il reconcile mancato non e' fatale
|
||||
out = capsys.readouterr().out
|
||||
assert "reconcile: pos illeggibile (RuntimeError)" in out
|
||||
assert "asset falliti" not in out # NON e' trattato come giro fallito
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione comunque
|
||||
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Il formato delle righe stampate non e' cambiato: tradesdb.parse_cron_log deve continuare a
|
||||
# leggerlo, sia in un giro normale sia in un giro con RebalanceParziale (#23).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill_btc = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderOk:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill_btc] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 180.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T13:47:01Z cron_book =====\n{out}===== done 13:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "buy" and abs(f.qty - 0.003) < 1e-9 and f.verified == 1
|
||||
|
||||
|
||||
def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderFlip())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit:
|
||||
pass
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T14:47:01Z cron_book =====\n{out}===== done 14:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "sell" and abs(f.qty - 0.002) < 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_open_cap (#18): chiamato SEMPRE, diretto (l'hasattr era solo per il periodo prima del
|
||||
# merge con U1, in cui DeribitTrader non lo aveva ancora — tolto qui).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderConCap:
|
||||
def __init__(self):
|
||||
self.cap_visti = []
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
self.cap_visti.append((inst, cap_usd, mark))
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderConCap()
|
||||
canned = _canned_report(cap_per_asset=300.0, scala=1.25)
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
assert trader.cap_visti == [("BTC_USDC-PERPETUAL", 300.0 * 1.25, 60000.0)] # solo BTC ha un ordine
|
||||
|
||||
|
||||
def test_trader_senza_set_open_cap_fallisce_isolato_non_lascia_morire_il_giro(monkeypatch, capsys):
|
||||
"""Post-merge: `set_open_cap` si chiama diretto, senza hasattr (era solo transitorio prima
|
||||
del merge con U1). Un trader che non lo espone ora fa fallire QUELL'asset (isolamento per
|
||||
asset, gia' esistente) — non l'intero giro: ETH resta elaborato normalmente."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderSenzaCap:
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report() # BTC ha un ordine (set_open_cap si chiama), ETH order=None
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
assert "AttributeError" in out # isolato: si vede nel log, non muore il giro
|
||||
assert "asset falliti in questo giro: BTC" in out
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #24 — ensure_disaster_sl "unknown" in book_execute: NON e' "naked" (non si e' tolto nulla),
|
||||
# ma il giro resta degradato -> notify dedicata e exit 2.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ensure_disaster_sl_unknown_notifica_e_degrada_il_giro(monkeypatch, capsys):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderUnknown:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
if inst.startswith("ETH"):
|
||||
return {"state": "unknown", "notes": "RuntimeError: 502 su get_positions"}
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderUnknown()
|
||||
canned = _canned_report() # BTC ha un ordine, ETH order=None -> ETH e' l'asset "unknown"
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert uscita == 2
|
||||
assert "ETH: protezione NON VERIFICATA" in out
|
||||
notifiche = [d for t, d in alerts if "non verificabile" in t.lower()]
|
||||
assert len(notifiche) == 1
|
||||
assert notifiche[0]["asset"] == "ETH"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# execution.py::_submit (#21) — legge il tetto con getattr: un DeribitTrader creato senza
|
||||
# passare da __init__ (object.__new__, come qui) non deve rompersi con AttributeError perche'
|
||||
# gli manca l'attributo _open_cap.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_submit_su_trader_senza_open_cap_non_solleva_attributeerror(monkeypatch):
|
||||
t = object.__new__(DeribitTrader) # bypassa __init__: niente self._open_cap
|
||||
monkeypatch.setattr(t, "_post", lambda path, payload: {
|
||||
"order": {"order_id": "1", "order_state": "filled",
|
||||
"filled_amount": payload["amount"]},
|
||||
"trades": [{"price": 1.0, "amount": payload["amount"], "fee": 0.0}]})
|
||||
dentro = MAX_AMOUNT["ETH_USDC-PERPETUAL"] * 0.5
|
||||
f = t._submit("ETH_USDC-PERPETUAL", "buy", dentro, reduce_only=False, label="book-open")
|
||||
assert f.verified is True # arrivato in fondo: niente AttributeError sul cap
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tre riparazioni sul percorso con soldi veri (unita' U1, 2026-09-27), provate contro il codice
|
||||
che dovrebbe farle — non discusse.
|
||||
|
||||
#17. `DeribitRead.position_usd` leggeva `size`/`size_currency` in ASSOLUTO e ignorava
|
||||
`direction`: uno short risultava un long, e close()/ensure_disaster_sl decidevano lato
|
||||
ordine e prezzo dello stop dal segno sbagliato.
|
||||
#18. Tetto per ordine d'apertura: `MAX_AMOUNT` era un tetto FISSO uguale per tutti i conti;
|
||||
ora il libro deriva il proprio tetto dal cap di config via `set_open_cap()`.
|
||||
#24. `DeribitRead.open_orders` ingoiava gli errori di ENTRAMBE le chiamate ('all' e
|
||||
'trigger_all') e ritornava []: un bracket disaster-SL perso passava per "nessun bracket".
|
||||
Ora solleva, e `ensure_disaster_sl` distingue lo stato "unknown" (letture fallite, non si
|
||||
cancella ne' si piazza) da "naked" (protezione tolta e non rimessa).
|
||||
|
||||
Nessun test tocca la rete.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.live.deribit import DeribitRead
|
||||
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError
|
||||
|
||||
ETH = "ETH_USDC-PERPETUAL"
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #17 — position_usd: il segno viene da `direction`, non assunto dal solo `size`.
|
||||
# =================================================================================================
|
||||
class _FakeRead(DeribitRead):
|
||||
"""DeribitRead senza rete: `positions()` finto, per esercitare `position_usd` con la forma
|
||||
REALE del gateway (size in assoluto + direction a parte)."""
|
||||
|
||||
def __init__(self, raw: list[dict]) -> None:
|
||||
self._raw = raw
|
||||
|
||||
def positions(self, currency):
|
||||
return self._raw
|
||||
|
||||
|
||||
def test_position_usd_sell_e_negativo():
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
|
||||
assert r.position_usd(ETH) == -560.0
|
||||
|
||||
|
||||
def test_position_usd_buy_e_positivo():
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "buy"}])
|
||||
assert r.position_usd(ETH) == 560.0
|
||||
|
||||
|
||||
def test_position_usd_senza_direction_si_fida_del_segno_di_size():
|
||||
"""Compatibile con un gateway che un giorno mandasse gia' il segno in `size`."""
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": -3}])
|
||||
assert r.position_usd(ETH) == -3.0
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# Verifica a valle di #17: close() e ensure_disaster_sl() decidono lato/stop da `pos > 0`.
|
||||
# Si mocka `positions()` (dati grezzi del gateway), NON `position_usd()`: cosi' si esercita
|
||||
# davvero la pipeline positions() -> position_usd() -> close()/ensure_disaster_sl().
|
||||
# =================================================================================================
|
||||
class _FakeTraderShort(DeribitTrader):
|
||||
"""DeribitTrader senza rete: posizione SHORT letta come il gateway la manda davvero, mark
|
||||
fisso, `_submit` registra la chiamata invece di piazzare l'ordine."""
|
||||
|
||||
def __init__(self, raw_positions: list[dict], mark: float = 2694.0) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
self._raw = raw_positions
|
||||
self._mark = mark
|
||||
self.submitted: list[dict] = []
|
||||
self.cancelled: list[str] = []
|
||||
|
||||
def positions(self, currency):
|
||||
return self._raw
|
||||
|
||||
def mark_price(self, instrument):
|
||||
return self._mark
|
||||
|
||||
def open_orders(self, instrument):
|
||||
return []
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
self.cancelled.append(order_id)
|
||||
return {}
|
||||
|
||||
def _submit(self, instrument, side, amount, *, reduce_only, label,
|
||||
order_type="market", price=None):
|
||||
self.submitted.append(dict(instrument=instrument, side=side, amount=amount,
|
||||
reduce_only=reduce_only, label=label,
|
||||
order_type=order_type, price=price))
|
||||
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
|
||||
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||
|
||||
|
||||
def _short_560():
|
||||
return _FakeTraderShort([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
|
||||
|
||||
|
||||
def test_close_su_short_usa_side_buy_reduce_only():
|
||||
t = _short_560()
|
||||
f = t.close(ETH)
|
||||
assert f is not None
|
||||
assert len(t.submitted) == 1
|
||||
call = t.submitted[0]
|
||||
assert call["side"] == "buy" and call["reduce_only"] is True
|
||||
assert call["label"] == "tp01-close" and call["amount"] > 0
|
||||
|
||||
|
||||
def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy():
|
||||
t = _short_560()
|
||||
ds = t.ensure_disaster_sl(ETH, 0.30)
|
||||
assert ds["state"] == "placed"
|
||||
assert len(t.submitted) == 1
|
||||
call = t.submitted[0]
|
||||
assert call["side"] == "buy"
|
||||
assert call["order_type"] == "stop_market"
|
||||
assert call["price"] > t._mark # stop SOPRA il mark per uno short
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #18 — set_open_cap(): tetto di apertura per strumento, derivato dal cap di config.
|
||||
# =================================================================================================
|
||||
class _FakeTraderCap(DeribitTrader):
|
||||
"""DeribitTrader senza rete: `_post` risponde con un ordine fillato finto, cosi' la parte di
|
||||
_submit DOPO il guardrail (che qui non ci interessa) non tocca la rete."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
|
||||
def _post(self, path, payload):
|
||||
amt = payload["amount"]
|
||||
return {"order": {"order_id": "1", "order_state": "filled", "filled_amount": amt},
|
||||
"trades": [{"price": 1.0, "amount": amt, "fee": 0.0}]}
|
||||
|
||||
|
||||
def test_set_open_cap_valida_gli_argomenti():
|
||||
t = _FakeTraderCap()
|
||||
with pytest.raises(ValueError):
|
||||
t.set_open_cap(ETH, 0.0, 2694.0)
|
||||
with pytest.raises(ValueError):
|
||||
t.set_open_cap(ETH, 2291.5, 0.0)
|
||||
|
||||
|
||||
def test_set_open_cap_apertura_entro_il_tetto_non_solleva():
|
||||
"""Equity 4583, cap/asset 2291.5, ETH mark 2694 -> apertura di 0,2807 ETH e' ben dentro il
|
||||
tetto (1,10 * 2291.5 / 2694 ~= 0,9355 ETH)."""
|
||||
t = _FakeTraderCap()
|
||||
t.set_open_cap(ETH, 2291.5, 2694.0)
|
||||
t._submit(ETH, "buy", 0.2807, reduce_only=False, label="book-open") # non deve solllevare
|
||||
|
||||
|
||||
def test_set_open_cap_3x_il_tetto_solleva_guardrail():
|
||||
t = _FakeTraderCap()
|
||||
t.set_open_cap(ETH, 2291.5, 2694.0)
|
||||
cap = t._open_cap[ETH]
|
||||
with pytest.raises(GuardrailError):
|
||||
t._submit(ETH, "buy", cap * 3, reduce_only=False, label="book-open")
|
||||
|
||||
|
||||
def test_senza_set_open_cap_resta_il_default_max_amount():
|
||||
t = _FakeTraderCap()
|
||||
dentro = MAX_AMOUNT[ETH] * 0.5
|
||||
t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare
|
||||
with pytest.raises(GuardrailError):
|
||||
t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open")
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #24 — open_orders non ingoia gli errori; ensure_disaster_sl diventa "unknown", non a caso.
|
||||
# =================================================================================================
|
||||
class _FakeOpenOrders(DeribitRead):
|
||||
"""DeribitRead senza rete: 'all' risponde vuoto, 'trigger_all' solleva — replica un bracket
|
||||
disaster-SL perso su un solo dei due tipi di chiamata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass # niente token, niente rete
|
||||
|
||||
def _post(self, path, payload):
|
||||
if payload.get("type") == "trigger_all":
|
||||
raise RuntimeError("502 Server Error: Bad Gateway")
|
||||
return {"result": []}
|
||||
|
||||
|
||||
def test_open_orders_trigger_all_solleva_runtime_error():
|
||||
r = _FakeOpenOrders()
|
||||
with pytest.raises(RuntimeError, match="open_orders illeggibile"):
|
||||
r.open_orders(ETH)
|
||||
|
||||
|
||||
class _FakeTraderOpenOrdersRompe(DeribitTrader):
|
||||
"""position_usd legge una posizione REALE, ma open_orders solleva: ensure_disaster_sl non
|
||||
deve decidere su meta' dei dati."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
self.submitted: list[dict] = []
|
||||
self.cancelled: list[str] = []
|
||||
|
||||
def positions(self, currency):
|
||||
return [{"instrument_name": ETH, "size": 560, "direction": "sell"}]
|
||||
|
||||
def mark_price(self, instrument):
|
||||
return 2694.0
|
||||
|
||||
def open_orders(self, instrument):
|
||||
raise RuntimeError("open_orders illeggibile: RuntimeError: 502 Server Error: Bad Gateway")
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
self.cancelled.append(order_id)
|
||||
return {}
|
||||
|
||||
def _submit(self, instrument, side, amount, *, reduce_only, label,
|
||||
order_type="market", price=None):
|
||||
self.submitted.append(dict(side=side, reduce_only=reduce_only))
|
||||
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
|
||||
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||
|
||||
|
||||
def test_ensure_disaster_sl_con_open_orders_che_solleva_diventa_unknown_senza_agire():
|
||||
t = _FakeTraderOpenOrdersRompe()
|
||||
ds = t.ensure_disaster_sl(ETH, 0.30)
|
||||
assert ds["state"] == "unknown"
|
||||
assert "502" in ds["notes"]
|
||||
assert t.cancelled == [] # nessuna cancel
|
||||
assert t.submitted == [] # nessun piazzamento
|
||||
@@ -162,3 +162,54 @@ def test_un_fill_non_misurabile_e_None_non_zero():
|
||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
|
||||
# fee nulla e' invece una misura legittima (ordine maker a rebate zero)
|
||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito
|
||||
# ===========================================================================
|
||||
# Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario
|
||||
# falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio
|
||||
# sarebbe sparito senza essere mai stato detto.
|
||||
|
||||
_RIS_AZIONE = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)},
|
||||
changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0,
|
||||
level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False,
|
||||
)
|
||||
_RIS_OK_SENZA_CAMBI = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False,
|
||||
)
|
||||
|
||||
|
||||
def _prepara(monkeypatch, risultato, esito_invio):
|
||||
salvato, inviato = [], []
|
||||
monkeypatch.setattr(FW, "run", lambda: risultato)
|
||||
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
|
||||
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
|
||||
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {})
|
||||
return salvato, inviato
|
||||
|
||||
|
||||
def test_invio_fallito_non_salva_lo_stato(monkeypatch):
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False)
|
||||
rc = FW.main()
|
||||
assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio"
|
||||
assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio"
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_invio_riuscito_salva_lo_stato(monkeypatch):
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"]
|
||||
|
||||
|
||||
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
|
||||
"""Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify."""
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False)
|
||||
FW.main()
|
||||
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
|
||||
assert len(salvato) == 1
|
||||
|
||||
@@ -31,12 +31,16 @@ def db(tmp_path):
|
||||
|
||||
def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch):
|
||||
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
|
||||
oggi = datetime.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
|
||||
feed_skh_min=0))
|
||||
class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
oggi = _OraFissa.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, oggi)
|
||||
assert s["giorno_in_corso"] is True
|
||||
assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1
|
||||
assert s["atteso_giri"] == 15 # ore 14:50, il giro delle 14 (:47) e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #27, #35 C9/C15/C7) — journal.py.
|
||||
|
||||
Non ripete la suite esistente: la tocca solo dove il comportamento CAMBIA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
|
||||
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
|
||||
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
|
||||
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
|
||||
giorno_in_corso=False, giri_mancanti=0))
|
||||
|
||||
|
||||
def _libro(**asset):
|
||||
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
|
||||
azione="HOLD (a target)"), **v) for k, v in asset.items()}
|
||||
lordo = sum(abs(v["posizione"]) for v in a.values())
|
||||
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
|
||||
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
|
||||
|
||||
|
||||
def _pnl(**kw):
|
||||
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
|
||||
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
|
||||
cumulato_da_arming=0.0, letture_equity=24)
|
||||
return {**base, **kw}
|
||||
|
||||
|
||||
def _ids(righe):
|
||||
return [r["id"] for r in righe]
|
||||
|
||||
|
||||
def _serie_equity(con, punti):
|
||||
for ts, eq in punti:
|
||||
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test"))
|
||||
con.commit()
|
||||
|
||||
|
||||
def _dichiara(monkeypatch, tmp_path, righe):
|
||||
f = tmp_path / "mov.jsonl"
|
||||
f.write_text("\n".join(json.dumps(r) for r in righe) + "\n")
|
||||
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
|
||||
return f
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #27 — due dichiarati nello stesso intervallo si SOMMANO, non si sovrascrivono
|
||||
# ============================================================================================
|
||||
|
||||
def test_due_dichiarati_nello_stesso_intervallo_si_sommano(db, monkeypatch, tmp_path):
|
||||
"""+25 e +30 dichiarati nello stesso intervallo, trading reale +5: prima del fix il
|
||||
secondo dichiarato sovrascriveva il primo (delta finale 30, non 55)."""
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 1000.0),
|
||||
("2026-08-25T08:07:01+00:00", 1060.0)]) # +60 = 25+30 mov. +5 trading
|
||||
_dichiara(monkeypatch, tmp_path, [
|
||||
dict(ts_utc="2026-08-25T07:20:00+00:00", delta_usd=25.0, nota="primo versamento"),
|
||||
dict(ts_utc="2026-08-25T07:40:00+00:00", delta_usd=30.0, nota="secondo versamento")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1 # UN evento, non due
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato" and e["delta"] == pytest.approx(55.0) # 25+30, non 30
|
||||
assert e["dopo"] == pytest.approx(1055.0)
|
||||
assert "primo versamento" in e["dichiarazione"] and "secondo versamento" in e["dichiarazione"]
|
||||
assert r["certi"] == pytest.approx(55.0)
|
||||
assert r["trading"] == pytest.approx(5.0) # 1060-1000-55
|
||||
# solo due letture: l'intero intervallo e' il movimento, nessun segmento di trading — 0%
|
||||
# e' il limite DICHIARATO di rendimento_twr ("nessun segmento = nessun tempo a mercato"),
|
||||
# non un valore inventato.
|
||||
assert r["twr"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_dichiarato_su_rilevato_resta_come_prima_anche_con_un_altro_dichiarato_altrove(db, monkeypatch, tmp_path):
|
||||
"""Non regressione: l'aggancio dichiarato+rilevato non deve rompersi perche' ora si guarda
|
||||
anche ad altri dichiarati."""
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: __import__("pandas").Series(
|
||||
100.0, index=__import__("pandas").date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")))
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
|
||||
("2026-08-25T11:47:01+00:00", 2066.88),
|
||||
("2026-08-25T12:47:01+00:00", 2064.56)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
|
||||
nota="bonifico")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C9 — il giro dell'ora corrente conta solo dopo che e' scattato (minuto :47 + margine)
|
||||
# ============================================================================================
|
||||
|
||||
def test_atteso_giri_al_minuto_10_non_conta_ancora_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 10, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=14, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 14 # il giro delle 14 (:47) non e' ancora scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
def test_atteso_giri_al_minuto_50_conta_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 15 # dopo le :47 il giro delle 14 e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C15 — "ultimi 7 giorni" deve coprire 7 giorni di calendario, non 8
|
||||
# ============================================================================================
|
||||
|
||||
def test_la_finestra_di_concentrazione_e_di_7_giorni_non_8(db):
|
||||
base = date(2026, 6, 24) # base .. base+13 = 14 giorni prima del giorno
|
||||
righe = []
|
||||
for i in range(7): # giorni 0..6: piatto
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1000.0), (f"{d}T18:00:00+00:00", 1000.0)]
|
||||
d7 = (base + timedelta(days=7)).isoformat() # giorno-7: il salto avviene QUI, a meta' giornata
|
||||
righe += [(f"{d7}T06:00:00+00:00", 1000.0), (f"{d7}T18:00:00+00:00", 1020.0)]
|
||||
for i in range(8, 14): # giorni 8..13: piatto al nuovo livello
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1020.0), (f"{d}T18:00:00+00:00", 1020.0)]
|
||||
d14 = (base + timedelta(days=14)).isoformat() # il giorno della voce
|
||||
righe += [(f"{d14}T06:00:00+00:00", 1020.0), (f"{d14}T18:00:00+00:00", 1100.0)]
|
||||
_serie_equity(db, righe)
|
||||
assert len(righe) == 30 # >24: la sezione si attiva
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[]), giorno=d14)
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "concentrazione")
|
||||
# baseline corretta = ULTIMA lettura di giorno-7 (1020, alle 18:00), non la PRIMA (1000,
|
||||
# alle 06:00): (1100-1020)/(1100-1000) = 80%. Con la finestra sbagliata (8 giorni di
|
||||
# calendario) sarebbe (1100-1000)/100 = 100%.
|
||||
assert "80%" in riga["testo"]
|
||||
assert "100%" not in riga["testo"]
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C7 — il drawdown si legge sulla serie AL NETTO dei movimenti certi
|
||||
# ============================================================================================
|
||||
|
||||
def test_un_prelievo_non_diventa_un_drawdown_di_trading(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
punti = [base + timedelta(hours=i) for i in range(26)] # 26 letture, >24
|
||||
for t in punti[:-1]:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), 1000.0, "t"))
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (punti[-1].isoformat(), 800.0, "t"))
|
||||
db.commit()
|
||||
mov = dict(classe="movimento", delta=-200.0, ts_prima=punti[-2].isoformat(),
|
||||
ts_dopo=punti[-1].isoformat(), prima=1000.0, dopo=800.0, pct=-0.2,
|
||||
fonte="dichiarato", dichiarazione="prelievo di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=punti[-1].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
# sulla serie grezza (picco 1000, ultima 800) sarebbe -20%: un prelievo, non una perdita.
|
||||
assert "drawdown" not in _ids(out)
|
||||
|
||||
|
||||
def test_un_versamento_non_nasconde_un_drawdown_di_trading_vero(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
letture = [(base + timedelta(hours=i), 1000.0) for i in range(20)] # piatto, 20 letture
|
||||
letture.append((base + timedelta(hours=20), 1500.0)) # versamento +500
|
||||
for h, v in ((21, 1490.0), (22, 1480.0), (23, 1475.0), (24, 1470.0)): # trading in calo
|
||||
letture.append((base + timedelta(hours=h), v))
|
||||
for t, val in letture:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), val, "t"))
|
||||
db.commit()
|
||||
assert len(letture) == 25 # >24
|
||||
mov = dict(classe="movimento", delta=500.0, ts_prima=letture[19][0].isoformat(),
|
||||
ts_dopo=letture[20][0].isoformat(), prima=1000.0, dopo=1500.0, pct=0.5,
|
||||
fonte="dichiarato", dichiarazione="versamento di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=letture[-1][0].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "drawdown")
|
||||
# netto: picco 1000 (prima/al versamento), ultima 970 (1470-500) -> -3.00%. Sulla serie
|
||||
# grezza (picco 1500 gonfiato dal versamento) sarebbe -2.00%: sottostima il vero calo.
|
||||
assert "-3.00%" in riga["testo"]
|
||||
assert "-2.00%" not in riga["testo"]
|
||||
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
|
||||
assert "non leggibile" in src
|
||||
|
||||
|
||||
def test_gateway_usde_giu_non_rompe_la_lettura_usdc():
|
||||
def test_gateway_usde_giu_rende_lequity_IGNOTA_non_solo_usdc():
|
||||
"""(#20) Comportamento cambiato: un'eccezione sulla lettura del conto USDE NON vale 0 USDE.
|
||||
Prima di questa riparazione, `eq` restava $2056 (solo USDC) come se l'USDE non ci fosse:
|
||||
se sul conto c'era davvero collaterale USDE, quel $2056 era un equity sottostimato spacciato
|
||||
per certo. Ora l'intera equity e' IGNOTA (None) — coerente col trattamento di ogni altra
|
||||
lettura fallita nel modulo (posizione, mark): non si assume, si segnala."""
|
||||
eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {})
|
||||
assert eq == 2056.0 and src == "mainnet USDC"
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
+10
-2
@@ -116,7 +116,11 @@ def test_fifo_non_mescola_gli_asset():
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # la vendita ETH non chiude il long BTC
|
||||
assert set(aperti) == {"BTC"}
|
||||
# (#32 C2) una vendita ETH senza lotti long apre un SHORT — non sparisce piu' in silenzio:
|
||||
# e' comunque isolata dal long BTC, che e' quel che il test verifica.
|
||||
assert set(aperti) == {"BTC", "ETH"}
|
||||
assert aperti["BTC"][0]["direzione"] == "long"
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_fifo_riproduce_il_conto_del_libro_vero():
|
||||
@@ -200,8 +204,12 @@ def test_l_esecutore_non_data_piu_i_fill_alla_barra_di_segnale():
|
||||
"""Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`.
|
||||
|
||||
Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte.
|
||||
Dal refactor U2 (#23) la registrazione di un fill vive in `_registra_fill(...)`, che riceve
|
||||
`bar_ts` come parametro invece di leggere `r['last_data']` alla riga del log_event: il call
|
||||
site passa `bar_ts=r["last_data"]`.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text()
|
||||
assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src
|
||||
assert "bar_ts=str(pd.Timestamp(r['last_data']))" in src
|
||||
assert "bar_ts=str(pd.Timestamp(bar_ts))" in src # dentro _registra_fill
|
||||
assert 'bar_ts=r["last_data"]' in src # il call site passa la barra
|
||||
assert "ts_utc=datetime.now(timezone.utc)" in src
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #32 C2, C10) — tradesdb.py.
|
||||
|
||||
Non ripete la suite esistente (`test_tradesdb.py`): quella resta bit-exact sui casi
|
||||
long-aperto/long-chiuso (vedi il commit). Qui solo cio' che il fix aggiunge: lotti SHORT,
|
||||
inversione in un fill, e i fill a qty<=0 fuori dal FIFO.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def _f(ts, asset, side, qty, price, fee=0.0, verified=1):
|
||||
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee,
|
||||
verified=verified)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- short: apertura e chiusura
|
||||
|
||||
def test_una_vendita_senza_lotti_long_apre_un_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # niente da chiudere: solo apertura
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0)
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(100.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_un_acquisto_chiude_il_short_pnl_entry_meno_exit(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0), # apre short @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 90.0, fee=1.0)] # chiude @90
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
r = rts[0]
|
||||
assert r["direzione"] == "short"
|
||||
assert r["px_in"] == 100.0 and r["px_out"] == 90.0
|
||||
assert r["pnl_lordo"] == pytest.approx(10.0) # venduto alto, comprato basso: profitto
|
||||
assert r["fee_quota"] == pytest.approx(2.0)
|
||||
assert r["pnl_netto"] == pytest.approx(8.0)
|
||||
assert aperti == {} # flat: nessun lotto residuo
|
||||
|
||||
|
||||
def test_chiusura_parziale_di_uno_short_lascia_il_resto_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0),
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 0.25, 80.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1 and rts[0]["qty"] == pytest.approx(0.25)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(0.25 * (100.0 - 80.0))
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(0.75)
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- inversione in un solo fill
|
||||
|
||||
def test_inversione_long_a_short_in_un_fill_non_scarta_il_resto(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), # long 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "sell", 2.0, 150.0)] # chiude + apre short
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "long" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(50.0) # (150-100)*1
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) # il resto: 2.0-1.0
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(150.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short" # nessun resto scartato
|
||||
|
||||
|
||||
def test_inversione_short_a_long_in_un_fill(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), # short 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 3.0, 90.0)] # chiude + apre long
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "short" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(10.0) # (100-90)*1
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(2.0) # il resto: 3.0-1.0
|
||||
assert aperti["ETH"][0]["direzione"] == "long"
|
||||
|
||||
|
||||
def test_stato_aperto_riporta_la_direzione_short(db):
|
||||
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0)
|
||||
assert st["ETH"]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- #32 C10: fill a qty<=0
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_nel_fifo(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
|
||||
_f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)])
|
||||
assert T.rebuild_roundtrips(db) == 1 # solo il round-trip vero, non uno spurio
|
||||
rt = db.execute("SELECT * FROM roundtrips").fetchall()
|
||||
assert len(rt) == 1 and rt[0]["qty"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_in_stato_aperto_ma_resta_nel_db(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0) # il fill a qty 0 non conta come lotto
|
||||
# ma resta leggibile nel DB, marcato NON verificato — non e' sparito, e' escluso dal FIFO
|
||||
righe = db.execute("SELECT qty, verified FROM fills WHERE qty = 0").fetchall()
|
||||
assert len(righe) == 1 and righe[0]["verified"] == 0
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
|
||||
@@ -0,0 +1,259 @@
|
||||
"""usde_convert.py — GTC mai cancellati (issue #26).
|
||||
|
||||
Il gateway cerbero-mcp NON accetta `time_in_force` (PlaceOrderReq non ha il campo, extra
|
||||
ignorati: gitea:Adriano/Cerbero-mcp#1): un limit riempito solo in parte resta APERTO per
|
||||
sempre, e senza guardia si accumula a ogni giro dello script. Blindano: (a) all'avvio si
|
||||
cancellano SOLO i residui con la nostra label (`usde-quota-*`), un ordine con altra label non
|
||||
si tocca; (b) nel ciclo, un ordine con `filled < amt` viene cancellato subito dopo; (c) se la
|
||||
cancellazione fallisce il ciclo si FERMA (mai un secondo ordine con un residuo vivo).
|
||||
|
||||
Client finto (nessuna rete), `time.sleep` patchato: nessun ordine vero puo' partire da questi
|
||||
test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
import usde_convert as C # noqa: E402
|
||||
from src.live import usde as U # noqa: E402
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# cancella_residui — SOLA lettura + cancellazione dei nostri ordini
|
||||
# =============================================================================================
|
||||
class _ClientOrdini:
|
||||
"""Fake minimale per _post/_unwrap: nessuna rete."""
|
||||
|
||||
def __init__(self, open_orders=None, open_orders_usde=None, risposte_place=None,
|
||||
boom_cancel=None):
|
||||
self.open_orders = open_orders or []
|
||||
# None = stessa lista di open_orders (retrocompat dei test esistenti, che non
|
||||
# distinguono la valuta interrogata); una lista propria serve solo a chi vuole
|
||||
# simulare un residuo elencato SOLO sotto USDE.
|
||||
self.open_orders_usde = open_orders_usde
|
||||
self.risposte_place = list(risposte_place or [])
|
||||
self.boom_cancel = boom_cancel or set() # order_id la cui cancel fallisce
|
||||
self.cancellati = []
|
||||
self.get_open_orders_chiamate = 0
|
||||
|
||||
def _post(self, path, payload):
|
||||
if path.endswith("get_open_orders"):
|
||||
self.get_open_orders_chiamate += 1
|
||||
if payload.get("currency") == "USDE" and self.open_orders_usde is not None:
|
||||
return {"result": self.open_orders_usde}
|
||||
return {"result": self.open_orders}
|
||||
if path.endswith("cancel_order"):
|
||||
oid = payload["order_id"]
|
||||
self.cancellati.append(oid)
|
||||
if oid in self.boom_cancel:
|
||||
# forma reale di client.py::cancel_order su un fallimento del venue: il gateway
|
||||
# avvolge il dict del tool in {"result": ...}, MAI in {"result": None, "error"}
|
||||
return {"result": {"order_id": oid, "state": "error", "error": "cancel rifiutata dal venue"}}
|
||||
return {"result": {"order_id": oid, "state": "cancelled"}}
|
||||
if path.endswith("place_order"):
|
||||
if not self.risposte_place:
|
||||
raise AssertionError("place_order chiamato senza risposte in coda")
|
||||
return self.risposte_place.pop(0)
|
||||
raise AssertionError(f"chiamata inattesa: {path}")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp.get("result", resp) if isinstance(resp, dict) else resp
|
||||
|
||||
|
||||
def test_cancella_residui_solo_le_nostre_label():
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, # NON nostro
|
||||
{"instrument": "BTC_USDC-PERPETUAL", "label": "usde-quota-0.64", "order_id": "R3"},
|
||||
])
|
||||
out = C.cancella_residui(client)
|
||||
assert out == ["R1"]
|
||||
assert client.cancellati == ["R1"], "l'ordine con altra label o su altro strumento non si tocca"
|
||||
|
||||
|
||||
def test_cancella_residui_trovato_solo_sotto_USDE():
|
||||
"""Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: se lo elenca
|
||||
sotto la base (USDE) invece che sotto la quotazione (USDC), la sola lettura USDC non lo
|
||||
vedrebbe mai e il residuo si accumulerebbe per sempre."""
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
open_orders_usde=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R9"}],
|
||||
)
|
||||
out = C.cancella_residui(client)
|
||||
assert out == ["R9"]
|
||||
assert client.cancellati == ["R9"]
|
||||
|
||||
|
||||
def test_cancella_residui_senza_residui_non_cancella_nulla():
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"},
|
||||
])
|
||||
assert C.cancella_residui(client) == []
|
||||
assert client.cancellati == []
|
||||
|
||||
|
||||
def test_cancella_residui_lettura_fallita_solleva():
|
||||
"""P5: non si ordina al buio. Se la LETTURA degli ordini aperti fallisce, la funzione
|
||||
deve sollevare invece di procedere come se non ci fosse nulla da cancellare."""
|
||||
class _ClientRotto:
|
||||
def _post(self, path, payload):
|
||||
raise RuntimeError("gateway giu'")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp
|
||||
|
||||
with pytest.raises(Exception):
|
||||
C.cancella_residui(_ClientRotto())
|
||||
|
||||
|
||||
def test_cancella_residui_un_residuo_che_non_si_cancella_solleva():
|
||||
"""Se non riusciamo a liberare un residuo NOSTRO, non si procede: si solleva (non si piazza
|
||||
un nuovo ordine sopra un residuo che non siamo riusciti a togliere)."""
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||
], boom_cancel={"R1"})
|
||||
with pytest.raises(Exception):
|
||||
C.cancella_residui(client)
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# _cancella — l'helper di basso livello usato sia da cancella_residui sia dal ciclo di main()
|
||||
# =============================================================================================
|
||||
def test__cancella_ok():
|
||||
client = _ClientOrdini()
|
||||
ok, motivo = C._cancella(client, "X1")
|
||||
assert ok and motivo == "" and client.cancellati == ["X1"]
|
||||
|
||||
|
||||
def test__cancella_errore_nel_payload_non_solleva():
|
||||
client = _ClientOrdini(boom_cancel={"X1"})
|
||||
ok, motivo = C._cancella(client, "X1")
|
||||
assert not ok and "rifiutata" in motivo
|
||||
|
||||
|
||||
def test__cancella_eccezione_di_rete_si_registra_non_si_propaga():
|
||||
class _Rotto:
|
||||
def _post(self, path, payload):
|
||||
raise RuntimeError("timeout")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp
|
||||
|
||||
ok, motivo = C._cancella(_Rotto(), "X1")
|
||||
assert not ok and "timeout" in motivo
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# main() con --esegui: cancellazione dei residui PRIMA del ciclo, e dopo ogni ordine parziale
|
||||
# =============================================================================================
|
||||
def _piano_finto(**over):
|
||||
base = dict(ok=True, side="buy", amount=10.0, px=1.0, px_ordine=1.0000, motivi=[],
|
||||
quota_ora=0.20, quota_target=0.64, cap_usd=None, cap_frac=0.0,
|
||||
val_usde=500.0, equity_tot=1500.0, delta_usd=10.0,
|
||||
usdc_dopo=990.0, cuscino=300.0, cuscino_come="test")
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _prepara_main(monkeypatch, client, tmp_path, argv_extra=()):
|
||||
monkeypatch.setattr(C, "DeribitRead", lambda: client)
|
||||
monkeypatch.setattr(C, "leggi_stato", lambda c: (1000.0, 500.0, 1.0, "test"))
|
||||
monkeypatch.setattr(C, "book_top", lambda *a, **k: (0.9999, 1.0001))
|
||||
monkeypatch.setattr(C, "piano", lambda *a, **k: _piano_finto())
|
||||
monkeypatch.setattr(C.time, "sleep", lambda s: None)
|
||||
monkeypatch.setattr(C, "LOG", tmp_path / "usde_convert.jsonl")
|
||||
monkeypatch.setattr(C, "ROOT", tmp_path) # solo per il print finale (LOG.relative_to(ROOT))
|
||||
monkeypatch.setattr(sys, "argv", ["usde_convert.py", "--quota", "0.64", "--esegui", *argv_extra])
|
||||
|
||||
|
||||
def test_ordine_con_filled_zero_viene_cancellato_e_il_ciclo_si_ferma(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "open",
|
||||
"filled_amount": 0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
rc = C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == ["O1"], "un ordine non fillato deve essere cancellato, non lasciato aperto"
|
||||
assert "residuo cancellato" in out
|
||||
assert rc == 1 # nessun fill -> esito negativo, comportamento invariato
|
||||
|
||||
|
||||
def test_cancel_che_fallisce_ferma_il_ciclo_e_non_manda_un_secondo_ordine(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[
|
||||
{"result": {"order": {"order_id": "BOOM", "order_state": "open",
|
||||
"filled_amount": 0}, "trades": []}},
|
||||
{"result": {"order": {"order_id": "O2", "order_state": "open",
|
||||
"filled_amount": 10}, "trades": []}}, # non deve mai partire
|
||||
],
|
||||
boom_cancel={"BOOM"},
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
rc = C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == ["BOOM"]
|
||||
assert "FALLITA" in out and "mai un secondo ordine" in out
|
||||
assert len(client.risposte_place) == 1, "il secondo ordine non deve essere stato piazzato"
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_ordine_fillato_per_intero_non_si_cancella(monkeypatch, tmp_path, capsys):
|
||||
"""filled == amt: nessun residuo, nessuna cancellazione (P3: non si spreca una chiamata su
|
||||
un ordine che non ha lasciato nulla aperto)."""
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||
"filled_amount": 10.0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
# piano() e' chiamato una volta PRIMA del ciclo (piano dry-run) e una volta per ogni giro:
|
||||
# le prime due chiamate (piano pre-ciclo + 1° giro) sono valide, la terza (2° giro) dice
|
||||
# 'gia' a bersaglio' per fermare il ciclo dopo il primo (e unico) ordine.
|
||||
chiamate = {"n": 0}
|
||||
|
||||
def piano_poi_fine(*a, **k):
|
||||
chiamate["n"] += 1
|
||||
if chiamate["n"] <= 2:
|
||||
return _piano_finto()
|
||||
return _piano_finto(ok=False, motivi=["gia' a bersaglio"])
|
||||
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||
C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == []
|
||||
assert "residuo cancellato" not in out
|
||||
|
||||
|
||||
def test_residui_all_avvio_vengono_cancellati_prima_del_ciclo(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "VECCHIO"}],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||
"filled_amount": 10.0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
# piano() e' chiamato una volta PRIMA del ciclo e una volta per giro: le prime due chiamate
|
||||
# sono valide (piano pre-ciclo + 1° giro, che piazza l'unico ordine), la terza ferma il ciclo.
|
||||
chiamate = {"n": 0}
|
||||
|
||||
def piano_poi_fine(*a, **k):
|
||||
chiamate["n"] += 1
|
||||
return _piano_finto() if chiamate["n"] <= 2 else _piano_finto(ok=False, motivi=["fine"])
|
||||
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||
C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "VECCHIO" in client.cancellati
|
||||
assert "residui cancellati all'avvio: 1" in out
|
||||
# e il residuo pre-esistente e' stato cancellato PRIMA che partisse il primo place_order
|
||||
assert client.cancellati.index("VECCHIO") == 0
|
||||
+44
-12
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
|
||||
assert r["apr"] is None and r["finestre"] == 0
|
||||
|
||||
|
||||
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def q(giorno, v):
|
||||
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
|
||||
|
||||
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
|
||||
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
|
||||
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
|
||||
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
|
||||
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
|
||||
assert q("2026-08-31", "NON_IDONEO") is False
|
||||
def test_quota_da_decidere_e_stata_rimossa():
|
||||
"""#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC
|
||||
(usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni
|
||||
giorno finche' non la si toglie a mano."""
|
||||
assert not hasattr(W, "quota_da_decidere")
|
||||
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
|
||||
|
||||
|
||||
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
|
||||
@@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano():
|
||||
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
|
||||
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
|
||||
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch)
|
||||
# =============================================================================================
|
||||
|
||||
def test_notify_fallito_la_condizione_si_ripresenta_come_nuova():
|
||||
"""Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva ->
|
||||
un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva
|
||||
l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'."""
|
||||
att = {"QUOTA_OVER"}
|
||||
prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO
|
||||
assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"}
|
||||
|
||||
|
||||
def test_notify_riuscito_non_si_ripete():
|
||||
att = {"QUOTA_OVER"}
|
||||
prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta
|
||||
assert W.nuove_condizioni(att, att, prev) == set()
|
||||
|
||||
|
||||
def test_record_vecchio_senza_allertati_vale_come_tutto_allertato():
|
||||
"""Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non
|
||||
deve produrre un rispam retroattivo delle condizioni gia' viste."""
|
||||
att = {"QUOTA_OVER", "BLIND"}
|
||||
prev = dict(stato="OK") # nessun campo `allertati`
|
||||
assert W.nuove_condizioni(att, att, prev) == set()
|
||||
|
||||
|
||||
def test_senza_record_precedente_tutto_e_nuovo():
|
||||
att = {"QUOTA_OVER", "DEPEG_WARN"}
|
||||
assert W.nuove_condizioni(att, set(), None) == att
|
||||
|
||||
|
||||
def test_una_condizione_appena_comparsa_e_sempre_nuova():
|
||||
"""Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per
|
||||
definizione — l'insieme confermato non la riguarda."""
|
||||
assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}
|
||||
|
||||
@@ -499,3 +499,84 @@ def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
|
||||
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
||||
assert "non tentato" in rep["invio"]
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default
|
||||
# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non
|
||||
# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo).
|
||||
# =============================================================================================
|
||||
|
||||
def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path):
|
||||
sp = tmp_path / "vw.json"
|
||||
|
||||
def fake():
|
||||
return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False,
|
||||
"assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None)
|
||||
for a in VW.ASSETS},
|
||||
"deribit_errors": []}
|
||||
monkeypatch.setattr(VW, "observe", fake)
|
||||
|
||||
rep = None
|
||||
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||
assert any(l == "BLIND" for l in rep["levels"].values())
|
||||
assert not any(l == "ALERT" for l in rep["levels"].values())
|
||||
assert rep["severity"] == "blind"
|
||||
|
||||
|
||||
def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path):
|
||||
"""Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9).
|
||||
|
||||
BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`);
|
||||
ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS`
|
||||
round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND."""
|
||||
sp = tmp_path / "vw.json"
|
||||
letture = {"n": 0}
|
||||
soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak
|
||||
|
||||
def fake():
|
||||
letture["n"] += 1
|
||||
eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0
|
||||
return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false",
|
||||
"maintenance": False,
|
||||
"assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None),
|
||||
"ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)},
|
||||
"deribit_errors": []}
|
||||
monkeypatch.setattr(VW, "observe", fake)
|
||||
|
||||
rep = None
|
||||
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||
assert rep["levels"]["BTC"] == "BLIND"
|
||||
assert rep["levels"]["ETH"] == "ALERT"
|
||||
assert rep["severity"] == "alert"
|
||||
|
||||
|
||||
def _carica_script_venue_watch():
|
||||
"""scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome
|
||||
del modulo `src.live.venue_watch` gia' importato sopra come `VW`."""
|
||||
import importlib.util
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
"venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py")
|
||||
m = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_titolo_blind_non_dice_prelievo():
|
||||
SVW = _carica_script_venue_watch()
|
||||
t = SVW._titolo({"severity": "blind"})
|
||||
assert "prelievo" not in t.lower()
|
||||
assert "CIECO" in t
|
||||
|
||||
|
||||
def test_titolo_alert_dice_ancora_prelievo():
|
||||
SVW = _carica_script_venue_watch()
|
||||
assert "prelievo" in SVW._titolo({"severity": "alert"}).lower()
|
||||
assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta
|
||||
|
||||
|
||||
def test_titolo_warn_resta_quello_di_prima():
|
||||
SVW = _carica_script_venue_watch()
|
||||
assert "Nessuna azione" in SVW._titolo({"severity": "warn"})
|
||||
|
||||
Reference in New Issue
Block a user