Files
PythagorasGoal/tests/test_book_live.py
T
Adriano Dal Pastro de83909db9 un flag sconosciuto non e' l'azione di default: guardia su 18 script, indice USDE orario, pulizia
MISURATO oggi durante la revisione: `trades_db.py --help` non stampava l'uso, cadeva in sync()
e riscriveva meta.ultimo_sync. Nessuno dei 18 script di scripts/live/ usava argparse: un flag
sbagliato era il ramo else. Su journal.py avrebbe scritto pagina e riga di DB, su analista.py
avrebbe speso una chiamata al modello e mandato un Telegram.

- src/live/cli.valida: prima istruzione di ogni __main__, prima di connect()/sync/rete.
  --help -> 0 con l'uso; flag ignoto, valore mancante o posizionale -> 2 con l'elenco dei
  previsti (P4). NIENTE argparse: cambierebbe messaggi, codici d'uscita e --help di script
  che il cron gia' chiama.
- 18 script cablati (i 3 che scrivono + 14 + cc01), flag invariati.
- tests/test_cli_flag.py (30): elenco DERIVATO dalla cartella (P1), valida come prima
  istruzione, uso che documenta i flag, e i flag che il CRON usa davvero restano accettati
  (P15/P16); end-to-end su --help e flag ignoto con trades.db non toccato (M15).
  Verificato a mano: monitor_health --quiet, trades_db --sync --quiet, book_execute dry-run.

Debito §5.15, primo passo: balance_watch (orario) registra `usde_usdc` a ogni campione — None
con la ragione se illeggibile, mai 1,0. Il cablaggio nel bound quando la serie ha storia.

Pulizia dalla revisione: tests/helpers.carica_script al posto della 15a copia del loader
importlib (5 file del libro live); il fill di prova via upsert_fills invece di un INSERT che
lasciava verified NULL; asserzioni non ancorate al padding; movimenti_capitale accetta le
righe gia' lette (una SELECT invece di due ai due lati di una scrittura del cron).

Test 910 verdi (+52). Diario 2026-09-02c.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
2026-09-02 15:49:40 +00:00

569 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)."""
# `scripts/` non e' un package importabile per nome: si carica via percorso
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
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)."""
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
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."""
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
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)."""
from helpers import carica_script
mod = carica_script("scripts/live/book_execute.py")
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)