"""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 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): 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 # --------------------------------------------------------------------------- # 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