From cfc42653cc8ec46ba90ae1eab7712ffde436cbc9 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:33:48 +0000 Subject: [PATCH 1/3] shadow: USDE illeggibile -> equity IGNOTA non 0; online richiede tutti i mark mainnet (#20, #25) Un'eccezione sulla lettura del conto USDE valeva 0 USDE: se il conto ne aveva davvero, l'equity totale risultava sottostimata senza che nulla lo dicesse. Ora _collaterale_usde ritorna None + la nota dell'eccezione, e _equity propaga l'ignoto (real_eq None -> eq_fallback, non un numero falso). shadow_report.online guardava solo il mark di BTC: con ETH in fallback il giro si considerava comunque online. Ora richiede TUTTI i mark da mainnet. Co-Authored-By: Claude Sonnet 5 --- src/live/execution.py | 69 ++++++++++++++++++++++------------ src/live/shadow.py | 27 ++++++++++---- tests/test_book_u2.py | 78 +++++++++++++++++++++++++++++++++++++++ tests/test_shadow_usde.py | 10 ++++- 4 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 tests/test_book_u2.py diff --git a/src/live/execution.py b/src/live/execution.py index 37bec30..7684e63 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -29,6 +29,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.""" @@ -150,36 +163,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, diff --git a/src/live/shadow.py b/src/live/shadow.py index 5996749..6c2963c 100644 --- a/src/live/shadow.py +++ b/src/live/shadow.py @@ -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, diff --git a/tests/test_book_u2.py b/tests/test_book_u2.py new file mode 100644 index 0000000..bc0e410 --- /dev/null +++ b/tests/test_book_u2.py @@ -0,0 +1,78 @@ +"""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 + +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 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 diff --git a/tests/test_shadow_usde.py b/tests/test_shadow_usde.py index 3284af5..e83e944 100644 --- a/tests/test_shadow_usde.py +++ b/tests/test_shadow_usde.py @@ -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 From 65a7f0d8fc2293f6957c46854577552072bfbaed Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:40:15 +0000 Subject: [PATCH 2/3] book_execute: SOLO PROTEZIONE su equity/SKH ignoti; fill mai persi su rebalance parziale (#21, #22, #23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prima, con equity reale non leggibile il giro dimensionava comunque un ordine sul paper_cap ($2.000 nominali) e lo mandava; con SKH ignoto il target restava quello (fail-safe solo nel segno). Ora: se non si sa quanto vale il conto o qual e' il segnale, nessun rebalance_signed — ma il disaster-SL si controlla LO STESSO per ogni asset (dipende da posizione/mark, non da equity/segnale). Giro degradato -> exit 2, solo se armato (non in anteprima dry-run). Fill persi: rebalance_signed puo' eseguire un fill (es. il close di un flip) e poi sollevare nel passo successivo (l'open dall'altra parte) — quel fill spariva perche' la lettura di `newpos`, PRIMA del giro che lo registrava, andava in eccezione a sua volta. Estratta la registrazione in _registra_fill(...), chiamata SUBITO dopo ogni fill (anche quelli di RebalanceParziale, nuova eccezione di execution.py con .fills/.causa) e PRIMA di leggere la posizione; la lettura ora e' in un try separato, non fatale (pos_after=None + "reconcile: pos illeggibile" se fallisce). Aggiornati due test che assumevano il comportamento vecchio (eq_fallback "prosegue" sul paper capital) e il guard sulla sorgente di test_tradesdb.py (bar_ts ora e' un parametro di _registra_fill, non piu' `r['last_data']` inline). Co-Authored-By: Claude Sonnet 5 --- scripts/live/book_execute.py | 102 ++++++++--- tests/test_book_live.py | 27 ++- tests/test_book_u2.py | 323 +++++++++++++++++++++++++++++++++++ tests/test_tradesdb.py | 6 +- 4 files changed, 425 insertions(+), 33 deletions(-) diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index 26e605b..39c1b44 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -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,7 +154,9 @@ 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']}") + # (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su + # quel segnale ignoto (modo SOLO PROTEZIONE, sotto) — i target restano informativi. + print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}") notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]}) # --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) -------------------------- @@ -220,11 +247,24 @@ 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 + if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto + print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}") + if do_execute: # (#21) segnalato: il modo SOLO PROTEZIONE e' sotto notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": 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") + 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 # nuovo dimensionamento accanto, cosi' il messaggio si legge senza aprire il repo. @@ -256,7 +296,12 @@ def _run(): 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". + motivo_breve = solo_protezione if len(solo_protezione) <= 60 else solo_protezione[:60] + "…" + 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 +313,28 @@ 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: + 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") @@ -336,7 +383,10 @@ def _run(): 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. + if scoperti or falliti or (solo_protezione and do_execute): sys.exit(2) diff --git a/tests/test_book_live.py b/tests/test_book_live.py index 8f3053c..ae5e176 100644 --- a/tests/test_book_live.py +++ b/tests/test_book_live.py @@ -535,8 +535,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 +554,30 @@ 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 any("equity fallback" in t for t, _ in alerts) # alert diagnostico esistente + assert any("solo protezione" in t.lower() for t, _ in alerts) # NUOVO alert di modo + assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato + assert uscita == 2 # giro degradato diff --git a/tests/test_book_u2.py b/tests/test_book_u2.py index bc0e410..f4f8a28 100644 --- a/tests/test_book_u2.py +++ b/tests/test_book_u2.py @@ -14,6 +14,8 @@ import json import sys from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) @@ -76,3 +78,324 @@ def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch): 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): + 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 + return trader, alerts, uscita + + +def test_eq_fallback_e_solo_protezione(monkeypatch): + canned = _canned_report( + eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000") + trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch) + assert trader.rebalance_chiamato is False # nessun ordine dimensionato + assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI + assert any("solo protezione" in t.lower() for t, _ in alerts) + assert uscita == 2 + + +def test_skh_error_e_solo_protezione(monkeypatch): + canned = _canned_report(skh_error="RuntimeError: feed 5m giu") + trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch) + assert trader.rebalance_chiamato is False + assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] + assert any("solo protezione" in t.lower() for t, _ in alerts) + 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 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 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 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 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 diff --git a/tests/test_tradesdb.py b/tests/test_tradesdb.py index eea0d0d..6d0129c 100644 --- a/tests/test_tradesdb.py +++ b/tests/test_tradesdb.py @@ -200,8 +200,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 From 9ce924d97c507a9712c83f998e5bf93c1c5f2662 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:41:07 +0000 Subject: [PATCH 3/3] book_execute: chiama set_open_cap (U1) se il trader lo espone, prima di rebalance_signed hasattr perche' U1 e U2 si uniscono dopo questo rilascio: DeribitTrader oggi non ha set_open_cap, e l'hasattr evita che l'unione dei due rami rompa in un senso o nell'altro. Si toglie l'hasattr al merge. Co-Authored-By: Claude Sonnet 5 --- scripts/live/book_execute.py | 5 ++++ tests/test_book_u2.py | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index 39c1b44..6bfe49f 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -314,6 +314,11 @@ def _run(): try: if do_execute and order is not None and not solo_protezione: + # U1/U2 si uniscono dopo questo rilascio: `set_open_cap` e' di U1 (execution.py, + # stesso file, non toccato qui). hasattr perche' oggi DeribitTrader non lo ha + # ancora — l'hasattr si toglie al merge, quando entrambi vivono nello stesso ramo. + if hasattr(trader, "set_open_cap"): + 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: diff --git a/tests/test_book_u2.py b/tests/test_book_u2.py index f4f8a28..845406f 100644 --- a/tests/test_book_u2.py +++ b/tests/test_book_u2.py @@ -399,3 +399,61 @@ def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsy 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 (da U1, stesso rilascio): chiamato SOLO se il trader lo espone (hasattr — U1 e +# U2 si uniscono dopo questo rilascio), subito prima di rebalance_signed. +# --------------------------------------------------------------------------- +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_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch): + """hasattr: un trader senza set_open_cap (oggi, prima del merge con U1) non deve rompersi.""" + 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() + 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"]) + mod._run() # non deve sollevare AttributeError