Files
PythagorasGoal/tests/test_book_live.py
T
Adriano Dal Pastro e69cc5bd81 test(book): i due test della formula del report avevano potenza ZERO a libro flat
I due confronti `abs(net_target - formula) < 1e-6` erano insoddisfacibili per
costruzione: `book_report` PUBBLICA valori arrotondati (`net_target` a 2 decimali,
`tp_frac` a 4) mentre l'ordine viene costruito sul valore non arrotondato
(`build_book_order(inst, net, ...)`), quindi un test che ricalcola la formula dal
`tp_frac` pubblicato eredita DUE arrotondamenti. Nessun ordine e' mai stato sbagliato:
lo scarto e' 0.5-1.5 centesimi su target da $114 e $954, con min_order a $5.

⚠️ Il difetto vero non e' la tolleranza, e' la POTENZA: con `tp_frac=0` e `skh_sign=0`
il target e' esattamente `0.0`, i due arrotondamenti sono esatti e l'invariante passa
senza essere mai esercitata. Dal 2026-06-23 al 2026-08-18 il book e' stato flat quasi
ininterrottamente -> per due mesi questi test non hanno verificato nulla su una formula
di produzione, e il difetto e' emerso solo quando TP01 e SKH01 sono andati long insieme.
Stessa lezione del 26/07 (test_skh_partial_entry): un self-check su eventi rari si
campiona sugli EVENTI, non sulla popolazione.

- `_budget_arrotondamento(equity)`: tolleranza DERIVATA (0.005 del round del net +
  WEIGHT*equity*W_TP01*0.00005 del round del tp_frac propagato), non tarata sul risultato.
- `test_la_formula_del_report_ha_potenza_anche_a_libro_flat`: segnale FORZATO su 4
  frazioni scomode (long+SKH long, long+SKH short, flat+SKH short, cap) con contatore
  che verifica che tutti i casi diano target != 0 -> la copertura non dipende dal mercato.
- `test_il_budget_di_arrotondamento_non_copre_un_errore_di_formula`: controllo POSITIVO,
  i modi reali di rompere la formula (pesi scambiati, cap non applicato, segno invertito)
  devono stare oltre 100x il budget. Una tolleranza che assolve tutto non e' una tolleranza.

Verificato per MUTAZIONE, non a occhio: `round(net, 0)` in book.py -> falliscono tutti e
tre (incluso il nuovo, che e' il punto); `W_TP01 <-> W_SKH` -> falliscono test_net_target_sizing
e il controllo positivo. src/live/book.py ripristinato bit-identico, produzione NON toccata.

38/38 in tests/test_book_live.py; suite 619 passati, 1 fallito (il noto
test_gtaa_band_gate, altro asse, invariato).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:17:27 +00:00

575 lines
29 KiB
Python

"""Test del BOOK DERIBIT-ONLY live (TP01+SKH01 nettati in software, un solo conto).
Coprono: la formula di netting (sizing 75/25 + cap, long/short/flat/flip), la PARITA' coi pesi del
backtest (deribit_book_sleeves), la sicurezza del gate (disarmato -> nessun ordine), e il reconcile
CON SEGNO (close+open sui flip, reduce reduce_only) — senza toccare la rete (trader fittizio).
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
# Data FRESCA per i report finti: dal 2026-07-25 book_execute ha uno staleness-gate bloccante
# (feed piu' vecchio di 2 giorni -> non esegue). Questi test riguardano skh_error / pos_error /
# eq_fallback, NON la staleness: con una data fissa marcirebbero appena supera la soglia.
# Lo staleness-gate ha i suoi test dedicati in tests/test_book_staleness_gate.py.
def _fresh_bar() -> str:
import pandas as _pd
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.book import W_SKH, W_TP01, book_net_target, build_book_order
from src.live.execution import Fill
# ---------------------------------------------------------------------------
# BUDGET DI ARROTONDAMENTO del report (2026-08-21).
# ---------------------------------------------------------------------------
def _budget_arrotondamento(equity: float) -> float:
"""Tolleranza per confrontare il `net_target` PUBBLICATO con la formula pura.
`book_report` pubblica valori arrotondati — `net_target` a 2 decimali e `tp_frac` a 4 —
mentre l'ordine viene costruito sul valore NON arrotondato (`build_book_order(inst, net, ...)`).
Un test che ricalcola la formula dal `tp_frac` pubblicato eredita quindi DUE arrotondamenti e
non puo' confrontare a 1e-6:
- `round(net, 2)` -> fino a 0.005 di scarto;
- `round(tp_frac, 4)` -> fino a 0.00005 di scarto sulla frazione, che la formula
amplifica per WEIGHT * equity * W_TP01.
⚠️ Questo NON e' un allentamento del test: i modi in cui la formula puo' rompersi davvero
(peso sbagliato, cap non applicato, segno invertito, W_TP01/W_SKH scambiati) valgono decine
di dollari su target da $114, tre ordini di grandezza sopra questo budget.
"""
from src.live.book import WEIGHT
return 0.005 + WEIGHT * equity * W_TP01 * 0.00005
# ---------------------------------------------------------------------------
# Formula di netting: 75/25, cap, combinazioni long/short/flat.
# ---------------------------------------------------------------------------
def test_net_target_sizing():
E, cap = 600.0, 300.0
assert book_net_target(0.0, 0, E, cap) == 0.0 # tutto flat
assert book_net_target(0.0, 1, E, cap) == 75.0 # solo SKH long = 0.25*0.5*600
assert book_net_target(0.0, -1, E, cap) == -75.0 # solo SKH short
assert book_net_target(1.0, 0, E, cap) == 225.0 # solo TP01 pieno = 0.75*0.5*600
assert book_net_target(1.0, -1, E, cap) == 150.0 # TP long + SKH short (hedge parziale)
assert book_net_target(1.0, 1, E, cap) == 300.0 # capped
assert book_net_target(2.0, 1, E, cap) == 300.0 # cap superiore
assert book_net_target(2.0, -1, E, cap) == 300.0 # 0.5*600*(1.5-0.25)=375 -> cap 300
def test_net_target_clamps_negative():
# TP flat e SKH short forte non sfora il cap negativo
assert book_net_target(0.0, -1, 4000.0, 300.0) == -300.0
# tp_frac negativo trattato come 0 (TP01 e' long-flat)
assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0
# ---------------------------------------------------------------------------
# CAP DINAMICO (frontiera 2026-07-03): con max_notional_per_asset_frac in config il
# cap = equity*frac (cresce col capitale). GUARDRAIL: dinamico SOLO con equity reale
# fidata; su fallback/offline/no-frac -> cap FISSO (protezione downside).
# ---------------------------------------------------------------------------
import json as _json
def _write_cfg(monkeypatch, tmp_path, watermark: float | None = None, **keys):
"""⚠️ Isola SIA la config SIA il watermark dell'equity. Senza la seconda riga di
monkeypatch questi test leggono `data/live/equity_seen.json` REALE e il loro esito dipende
da quanto c'e' sul conto — difetto trovato il 2026-07-26, quando l'aggiunta del watermark
fece fallire un test che non lo menzionava nemmeno."""
import src.live.book as book
p = tmp_path / "live.json"
p.write_text(_json.dumps(keys))
monkeypatch.setattr(book, "CONFIG", p)
wm = tmp_path / "equity_seen.json"
monkeypatch.setattr(book, "EQUITY_WATERMARK", wm)
if watermark is not None:
wm.write_text(_json.dumps({"real_equity": float(watermark)}))
return book
def test_cap_dynamic_scales_with_trusted_equity(monkeypatch, tmp_path):
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
# equity reale fidata -> cap = equity/2 (cresce col capitale)
assert book._cap(equity=5000.0, real_equity=5000.0, eq_fallback=None) == 2500.0
assert book._cap(equity=598.0, real_equity=598.0, eq_fallback=None) == 299.0 # ~inerte a $600
def test_cap_falls_back_to_fixed_on_eq_fallback(monkeypatch, tmp_path):
"""Senza watermark (conto mai visto) il fallback e' la taglia sicura, MAI equity/2 del paper
($1.000 su un conto ignoto = pericoloso). Comportamento storico, invariato."""
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
assert book._cap(equity=2000.0, real_equity=None, eq_fallback="paper_cap") == 300.0
assert book._cap(equity=2000.0, real_equity=None, eq_fallback=None) == 300.0 # real None -> fisso
assert book._cap(equity=0.0, real_equity=0.0, eq_fallback=None) == 300.0 # equity 0 -> fisso
def test_cap_di_fallback_non_supera_il_conto_osservato(monkeypatch, tmp_path):
"""2026-07-26: col cap di config alzato a $3.000 in previsione di un deposito, il fallback
deve restare legato all'ULTIMA EQUITY VISTA, altrimenti su un conto da $597 permetterebbe
$2.000 di nozionale lordo = 3.35x di leva proprio quando l'equity non e' leggibile."""
book = _write_cfg(monkeypatch, tmp_path, watermark=596.92,
max_notional_per_asset_usd=3000, max_notional_per_asset_frac=0.5)
cap = book._cap(equity=2000.0, real_equity=None, eq_fallback="paper_cap")
assert cap == 596.92 * 0.5
assert 2 * cap <= 596.92, "leva > 1x in fallback"
def test_cap_di_fallback_cresce_col_conto_dopo_un_deposito(monkeypatch, tmp_path):
book = _write_cfg(monkeypatch, tmp_path, watermark=6047.0,
max_notional_per_asset_usd=3000, max_notional_per_asset_frac=0.5)
assert book._cap(equity=2000.0, real_equity=None, eq_fallback="paper_cap") == 3000.0
def test_cap_fixed_when_no_frac_in_config(monkeypatch, tmp_path):
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300) # niente frac
assert book._cap(equity=5000.0, real_equity=5000.0, eq_fallback=None) == 300.0
def test_book_report_dynamic_cap_online(monkeypatch, tmp_path):
"""Integrazione: online con equity reale -> cap_per_asset = equity/2 e la formula lo rispetta."""
book = _write_cfg(monkeypatch, tmp_path, max_notional_per_asset_usd=300, max_notional_per_asset_frac=0.5)
base = book.shadow_report(offline=True, equity_override=5000.0)
monkeypatch.setattr(book, "shadow_report",
lambda **k: {**base, "real_equity": 5000.0, "equity": 5000.0, "eq_fallback": None})
r = book.book_report()
assert r["cap_per_asset"] == 2500.0
for a in r["assets"]:
expect = book_net_target(a["tp_frac"], a["skh_sign"], 5000.0, 2500.0)
assert abs(a["net_target"] - expect) < _budget_arrotondamento(5000.0)
def test_weights_match_backtest_sleeves():
"""I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves)."""
from src.portfolio.sleeves import deribit_book_sleeves
w = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
assert abs(w["TP01"] - W_TP01) < 1e-12 and abs(w["SKH01"] - W_SKH) < 1e-12
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
# ---------------------------------------------------------------------------
# Costruzione ordine: side, reduce_only, flip, close, soglia minima.
# ---------------------------------------------------------------------------
def test_build_order_open_long():
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 0.0, 60000.0, min_usd=5.0)
assert o["side"] == "buy" and not o["reduce_only"] and not o["needs_flip"] and not o["is_close"]
def test_build_order_reduce_same_sign():
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 150.0, 60000.0)
assert o["side"] == "sell" and o["reduce_only"] and not o["needs_flip"]
def test_build_order_flip():
o = build_book_order("BTC_USDC-PERPETUAL", -75.0, 75.0, 60000.0)
assert o["needs_flip"] and o["side"] == "sell"
def test_build_order_close_to_flat():
o = build_book_order("BTC_USDC-PERPETUAL", 0.0, 75.0, 60000.0)
assert o["is_close"] and o["reduce_only"] and o["side"] == "sell"
def test_build_order_below_min_is_none():
assert build_book_order("BTC_USDC-PERPETUAL", 75.0, 73.0, 60000.0, min_usd=5.0) is None
assert build_book_order("BTC_USDC-PERPETUAL", 0.0, 0.0, 60000.0) is None
# ---------------------------------------------------------------------------
# Sicurezza del GATE: disarmato (execution_enabled=false) -> do_execute False.
# ---------------------------------------------------------------------------
def test_gate_requires_both_switches():
def do_execute(enabled, want_execute):
return bool(want_execute) and bool(enabled)
assert not do_execute(False, False)
assert not do_execute(True, False) # armato ma senza --execute
assert not do_execute(False, True) # --execute ma disarmato
assert do_execute(True, True) # solo con entrambi
def test_config_default_disarmed(tmp_path, monkeypatch):
"""load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
import importlib
be = importlib.import_module("scripts.live.book_execute") if False else None
# carica il modulo via path (scripts/ non e' un package importabile per nome)
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
assert mod.load_config()["execution_enabled"] is False
# ---------------------------------------------------------------------------
# Reconcile CON SEGNO (long/short/flip) senza rete: trader fittizio.
# ---------------------------------------------------------------------------
class FakeTrader:
"""Replica la logica di DeribitTrader.rebalance_signed registrando le chiamate, senza rete."""
def __init__(self, pos):
self.pos = float(pos)
self.calls = []
def position_usd(self, instrument):
return self.pos
def _mk(self, side, amount, reduce_only, label):
self.calls.append((label, side, round(amount, 6), reduce_only))
return Fill(instrument="X", side=side, amount=amount, filled=amount, price=60000.0,
fee_usdc=0.0, order_id="1", state="filled", verified=True)
def close(self, instrument, label="x"):
if abs(self.pos) < 1.0:
return None
f = self._mk("sell" if self.pos > 0 else "buy", abs(self.pos) / 60000.0, True, label)
self.pos = 0.0
return f
def open(self, instrument, side, amount, label="x"):
f = self._mk(side, amount, False, label)
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
return f
def _submit(self, instrument, side, amount, *, reduce_only, label, **k):
f = self._mk(side, amount, reduce_only, label)
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
return f
# importa il metodo reale da DeribitTrader (testiamo proprio quella logica)
from src.live.execution import DeribitTrader as _DT
rebalance_signed = _DT.rebalance_signed
def test_reconcile_open_from_flat():
t = FakeTrader(0.0)
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
labels = [c[0] for c in t.calls]
assert labels == ["book-open"] and t.calls[0][1] == "buy"
def test_reconcile_flip_closes_then_opens():
t = FakeTrader(75.0) # long, target short -> flip
t.rebalance_signed("BTC_USDC-PERPETUAL", -75.0, 60000.0, min_usd=5.0)
labels = [c[0] for c in t.calls]
assert labels == ["book-flip-close", "book-open"]
assert t.calls[1][1] == "sell" # apre short dopo il close
def test_reconcile_reduce_same_sign_is_reduce_only():
t = FakeTrader(150.0)
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
assert [c[0] for c in t.calls] == ["book-reduce"]
assert t.calls[0][3] is True # reduce_only
def test_reconcile_target_flat_closes():
t = FakeTrader(75.0)
t.rebalance_signed("BTC_USDC-PERPETUAL", 0.0, 60000.0, min_usd=5.0)
assert [c[0] for c in t.calls] == ["book-exit"]
def test_reconcile_below_min_noop():
t = FakeTrader(73.0)
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
assert t.calls == []
# ---------------------------------------------------------------------------
# PARITA' d'integrazione: il report reale (offline, niente rete) applica ESATTAMENTE
# la formula pura per ogni asset -> wiring corretto e riproducibile.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Feed live effimero (src/live/livefeed): merge/dedup, fallback, loader onorato.
# Nessuna rete (la coda fresca è iniettata / il fetch è stubbato a errore).
# ---------------------------------------------------------------------------
def test_merge_tail_dedups_and_tail_wins():
import pandas as pd
from src.live.livefeed import merge_tail
base = pd.DataFrame({"timestamp": [0, 300000, 600000], "open": [1, 2, 3], "high": [1, 2, 3],
"low": [1, 2, 3], "close": [1, 2, 3], "volume": [1, 1, 1],
"datetime": pd.to_datetime([0, 300000, 600000], unit="ms", utc=True)})
tail = pd.DataFrame({"timestamp": [600000, 900000], "open": [9, 4], "high": [9, 4],
"low": [9, 4], "close": [9, 4], "volume": [2, 2]})
m = merge_tail(base, tail)
assert list(m["timestamp"]) == [0, 300000, 600000, 900000] # esteso + ordinato
assert m.loc[m["timestamp"] == 600000, "close"].iloc[0] == 9 # la coda VINCE sul duplicato
assert "datetime" in m.columns and m["datetime"].is_monotonic_increasing
def test_merge_tail_empty_returns_base():
import pandas as pd
from src.live.livefeed import merge_tail
base = pd.DataFrame({"timestamp": [0], "open": [1], "high": [1], "low": [1], "close": [1], "volume": [1]})
assert merge_tail(base, pd.DataFrame()).equals(base)
def test_fresh_5m_falls_back_to_certified_on_error(monkeypatch):
import src.live.livefeed as lf
from src.data.downloader import load_data
monkeypatch.setattr(lf, "_fetch_recent_5m", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("net")))
got = lf.fresh_5m("BTC")
base = load_data("BTC", "5m")
assert len(got) == len(base) and got["timestamp"].iloc[-1] == base["timestamp"].iloc[-1]
def test_skyhook_positions_honors_custom_loader():
from src.portfolio import sleeves
calls = []
def spy(a):
calls.append(a)
return sleeves.load_data(a, "5m")
pos = sleeves._skyhook_positions(load5m=spy)
assert set(pos.keys()) == {"BTC", "ETH"} and calls == ["BTC", "ETH"]
def test_book_report_uses_pure_formula_offline():
from src.live.book import book_report
r = book_report(offline=True, equity_override=600.0)
assert r["equity"] == 600.0 and r["cap_per_asset"] > 0
for a in r["assets"]:
expect = book_net_target(a["tp_frac"], a["skh_sign"], 600.0, r["cap_per_asset"])
assert abs(a["net_target"] - expect) < _budget_arrotondamento(600.0), \
f"{a['asset']}: net incoerente con la formula"
assert a["skh_sign"] in (-1, 0, 1)
# offline -> conto assunto flat -> nessuna posizione reale, report deterministico
assert all(a["position_usd"] == 0.0 for a in r["assets"])
def test_la_formula_del_report_ha_potenza_anche_a_libro_flat(monkeypatch):
"""⚠️ I due test qui sopra leggono il segnale REALE del giorno: quando il libro e' flat
(`tp_frac=0`, `skh_sign=0`) il target e' esattamente `0.0`, i due arrotondamenti del report
sono esatti e l'invariante passa **senza essere mai esercitata**.
E' successo davvero: dal 2026-06-23 al 2026-08-18 il book e' stato flat quasi ininterrottamente
e questi test hanno avuto potenza ZERO su una formula di produzione; il difetto di tolleranza
e' emerso solo il 2026-08-21, quando TP01 e SKH01 sono andati long insieme.
E' la lezione gia' codificata il 26/07 (`test_skh_partial_entry`): **un self-check su eventi
rari si campiona sugli EVENTI, non sulla popolazione.**
Qui il segnale e' FORZATO su frazioni scomode, cosi' la copertura non dipende dal mercato.
"""
import src.live.book as book
# frazioni scelte per NON essere clean a 4 decimali e per coprire i rami della formula:
# long parziale + SKH long, long parziale + SKH short (hedge), flat + SKH short, cap.
casi = [
(0.17578123, 1),
(0.29149997, -1),
(0.0, -1),
(1.0, 1), # -> clampato al cap
]
equity, cap = 5000.0, 2500.0
# ⚠️ `base` va preso PRIMA del loop: dalla seconda iterazione `book.shadow_report` e' gia'
# sostituito dal monkeypatch e si leggerebbe il finto della iterazione precedente.
base = book.shadow_report(offline=True, equity_override=equity)
non_banali = 0
for tp_vero, sign in casi:
finto = {**base, "real_equity": equity, "equity": equity, "eq_fallback": None,
"assets": [{**a, "target": tp_vero, "position_usd": 0.0} for a in base["assets"]]}
monkeypatch.setattr(book, "shadow_report", lambda *a, _f=finto, **k: _f)
monkeypatch.setattr(book, "_skyhook_positions",
lambda load5m=None, _s=sign: {a: ("flat" if _s == 0 else
{"dir": "LONG" if _s > 0 else "SHORT"})
for a in book.ASSETS})
r = book.book_report()
atteso_vero = book_net_target(tp_vero, sign, equity, cap)
non_banali += (atteso_vero != 0.0)
for a in r["assets"]:
assert a["skh_sign"] == sign
# (a) il valore pubblicato e' la formula sul tp_frac VERO, a meno del solo round(.,2)
assert abs(a["net_target"] - atteso_vero) <= 0.005, \
f"tp={tp_vero} sign={sign}: net pubblicato incoerente con la formula"
# (b) e ricalcolandola dal tp_frac PUBBLICATO resta dentro il budget dichiarato
da_pubblicato = book_net_target(a["tp_frac"], a["skh_sign"], equity, cap)
assert abs(a["net_target"] - da_pubblicato) < _budget_arrotondamento(equity)
# controllo di POTENZA: ogni caso deve produrre un target != 0, altrimenti il test
# passerebbe per lo stesso motivo per cui passava a libro flat (0.0 arrotondato e' 0.0).
assert non_banali == len(casi), f"solo {non_banali}/{len(casi)} casi esercitano l'invariante"
def test_il_budget_di_arrotondamento_non_copre_un_errore_di_formula():
"""Controllo POSITIVO del budget: deve restare molto piu' stretto dei modi in cui la
formula puo' rompersi davvero, o sarebbe una tolleranza che assolve tutto."""
E, cap = 5000.0, 2500.0
giusto = book_net_target(0.5, 1, E, cap)
budget = _budget_arrotondamento(E)
pesi_scambiati = 0.5 * E * (W_SKH * 0.5 + W_TP01 * 1) # W_TP01/W_SKH invertiti
senza_cap = 0.5 * E * (W_TP01 * 5.0 + W_SKH * 1) # cap non applicato
for sbagliato, nome in ((pesi_scambiati, "pesi scambiati"), (senza_cap, "cap non applicato"),
(-giusto, "segno invertito")):
assert abs(giusto - sbagliato) > 100 * budget, f"{nome}: il budget lo assolverebbe"
# ---------------------------------------------------------------------------
# SKH feed fallito: book_report NON crasha (forza flat + flagga skh_error) e
# book_execute DEVE farlo emergere (log + alert), non ingoiarlo silenziosamente.
# ---------------------------------------------------------------------------
def test_book_report_flags_skh_feed_error(monkeypatch):
import src.live.book as book
monkeypatch.setattr(book, "_skyhook_positions",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("feed 5m giu")))
r = book.book_report(offline=True, equity_override=600.0)
assert "skh_error" in r and "feed 5m giu" in r["skh_error"] # errore catturato + esposto
assert all(a["skh_sign"] == 0 for a in r["assets"]) # SKH forzato flat (fail-safe)
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = dict(
last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test",
cap_per_asset=300.0, skh_error="RuntimeError: feed 5m giu",
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0,
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
orders=[],
)
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
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()
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
# ---------------------------------------------------------------------------
# GATE FAIL-SAFE posizione: se la read della posizione reale fallisce (ONLINE ma
# posizione IGNOTA -> assunta flat), l'esecutore NON deve operare a cieco.
# ---------------------------------------------------------------------------
def test_positions_flags_read_failure():
from src.live.shadow import ASSETS, _positions
class BadClient:
def position_usd(self, inst):
raise RuntimeError("api 500")
pos, note, err = _positions(BadClient())
assert all(pos[a] == 0.0 for a in ASSETS) # assunta flat (fail-safe)
assert err is not None and "non leggibile" in err # ma SEGNALATA (non silenziosa)
def test_positions_ok_and_offline_have_no_error():
from src.live.shadow import ASSETS, _positions
class GoodClient:
def position_usd(self, inst):
return 123.0
_, _, err_ok = _positions(GoodClient())
_, _, err_off = _positions(None) # offline -> gestito dal gate 'online'
assert err_ok is None and err_off is None
def test_book_report_propagates_pos_error(monkeypatch):
import src.live.book as book
base = book.shadow_report(offline=True, equity_override=600.0)
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "pos_error": "IGNOTA"})
r = book.book_report(offline=True, equity_override=600.0)
assert r.get("pos_error") == "IGNOTA" # propagato fino al book
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = dict(
last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC",
cap_per_asset=300.0, skh_error=None,
pos_error="posizione non leggibile, assunta FLAT: BTC (RuntimeError: api 500)",
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=1.0, skh_sign=1,
skh_state="flat", net_target=225.0, position_usd=0.0, mark=60000.0,
order=dict(side="buy"))], # ordine presente: senza gate proverebbe a eseguire
orders=[dict(side="buy")],
)
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
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"]) # ARMATO + execute
def boom(*a, **k):
raise AssertionError("DeribitTrader NON deve essere costruito: il gate deve fermarsi prima")
monkeypatch.setattr(mod, "DeribitTrader", boom)
mod._run()
out = capsys.readouterr().out
assert "POSIZIONE NON LEGGIBILE" in out # stampato
assert any("posizione non leggibile" in t for t, _ in alerts) # alert inviato
# ---------------------------------------------------------------------------
# DIAGNOSTICA equity (Opzione B): ONLINE ma equity reale non leggibile -> sizing su
# paper_cap. NON blocca (l'hard-cap $/asset protegge), ma DEVE essere segnalato.
# ---------------------------------------------------------------------------
def test_shadow_flags_eq_fallback_when_online_but_equity_unreadable(monkeypatch):
import src.live.shadow as sh
monkeypatch.setattr(sh, "_safe_client", lambda: object()) # online
monkeypatch.setattr(sh, "_marks", lambda c, d: ({a: 60000.0 for a in sh.ASSETS},
{a: "mainnet" for a in sh.ASSETS}))
monkeypatch.setattr(sh, "_positions", lambda c: ({a: 0.0 for a in sh.ASSETS}, "mainnet", None))
monkeypatch.setattr(sh, "_equity", lambda c, m: (None, "conto flat / non finanziato")) # equity IGNOTA
r = sh.shadow_report() # online, no override
assert r["online"] is True and r["real_equity"] is None
assert r.get("eq_fallback") and "paper_cap" in r["eq_fallback"] # fallback SEGNALATO
def test_shadow_no_eq_fallback_offline_or_override():
from src.live.shadow import shadow_report
assert shadow_report(offline=True, equity_override=600.0).get("eq_fallback") is None # override
assert shadow_report(offline=True).get("eq_fallback") is None # offline != fallback-online
def test_book_report_propagates_eq_fallback(monkeypatch):
import src.live.book as book
base = book.shadow_report(offline=True, equity_override=600.0)
monkeypatch.setattr(book, "shadow_report", lambda **k: {**base, "eq_fallback": "PAPER_CAP"})
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)."""
import importlib.util
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
canned = 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="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000",
assets=[dict(asset="BTC", instrument="BTC_USDC-PERPETUAL", tp_frac=0.0, skh_sign=0,
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
orders=[],
)
alerts = []
class FakeDT:
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
def position_usd(self, inst): return 0.0
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, "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()
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)