book_execute: integrazione U1+U2 — un solo Telegram in solo protezione, stato unknown, set_open_cap diretto (#18, #21, #22, #24)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:14:28 +00:00
parent 0e6cf17081
commit 841d4c78cd
4 changed files with 166 additions and 36 deletions
+25 -9
View File
@@ -415,7 +415,10 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
"""Se il report porta skh_error, _run() lo stampa SEMPRE (anche in dry-run); quando il giro e'
ARMATO l'alert parte UNA sola volta, nel modo SOLO PROTEZIONE (#22) — niente flat silenzioso,
ma nemmeno due notify per lo stesso guasto (il vecchio notify incondizionato e' stato tolto,
U6 #22)."""
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
@@ -426,17 +429,27 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
orders=[],
)
class _TraderOk:
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
monkeypatch.setattr(mod, "load_config",
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
mod._run()
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
try:
mod._run()
except SystemExit:
pass
out = capsys.readouterr().out
assert "SKH FEED ERRORE" in out # stampato nel log
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
assert "SKH FEED ERRORE" in out # stampato nel log, sempre
assert len(alerts) == 1 # UNA sola notify, non due
assert "solo protezione" in alerts[0][0].lower()
# ---------------------------------------------------------------------------
@@ -576,8 +589,11 @@ def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
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 diagnostico esistente
assert any("solo protezione" in t.lower() for t, _ in alerts) # NUOVO alert di modo
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
+107 -15
View File
@@ -21,7 +21,7 @@ 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
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, RebalanceParziale
def _fresh_bar() -> str:
@@ -125,7 +125,7 @@ class _TraderSoloProtezione:
return {"state": "ok", "stop": 1.0, "amount": 0.0}
def _run_solo_protezione(canned, monkeypatch):
def _run_solo_protezione(canned, monkeypatch, capsys):
mod = carica_script("scripts/live/book_execute.py")
trader = _TraderSoloProtezione()
alerts = []
@@ -142,25 +142,34 @@ def _run_solo_protezione(canned, monkeypatch):
mod._run()
except SystemExit as e:
uscita = e.code
return trader, alerts, uscita
out = capsys.readouterr().out
return trader, alerts, uscita, out
def test_eq_fallback_e_solo_protezione(monkeypatch):
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 = _run_solo_protezione(canned, monkeypatch)
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
assert any("solo protezione" in t.lower() for t, _ in alerts)
# (#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):
def test_skh_error_e_solo_protezione(monkeypatch, capsys):
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch)
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"]
assert any("solo protezione" in t.lower() for t, _ in alerts)
# (#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
@@ -230,6 +239,9 @@ def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkey
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"))
@@ -285,6 +297,9 @@ def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monke
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 []
@@ -330,6 +345,9 @@ def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypa
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 []
@@ -368,6 +386,9 @@ def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsy
"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"))
@@ -402,8 +423,8 @@ def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsy
# ---------------------------------------------------------------------------
# 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.
# 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")
@@ -435,8 +456,10 @@ def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
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."""
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:
@@ -447,7 +470,7 @@ def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch):
def ensure_disaster_sl(self, inst, sl_pct):
return {"state": "ok", "stop": 1.0, "amount": 0.0}
canned = _canned_report()
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())
@@ -456,4 +479,73 @@ def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch):
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
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