567046953d
Terza+quarta chiusura del pattern "eccezione ingoiata -> stato safe silenzioso"
in src/live, dopo skh_error (31369b3). Emerse durante la verifica del gate SKH01.
Opzione A (gate fail-safe posizione):
- shadow._positions: se la read della posizione reale Deribit lancia, assume flat
MA ora ritorna un pos_error esplicito (prima solo una note mai propagata).
- Propagato shadow_report -> book_report -> book_execute: se ONLINE ma posizione
IGNOTA, l'esecutore NON opera a cieco (return + alert), come gia' per 'online'.
Opzione B (diagnostica equity, no halt):
- shadow._equity/shadow_report: se ONLINE ma equity reale non leggibile, il book
ripiega su paper_cap (~$2000) invece del conto reale (~$598) -> sovradimensiona
~33%, ma l'hard-cap $300/asset limita il downside. Nuovo flag eq_fallback ->
book_execute stampa warning + alert Telegram MA prosegue (niente gate: la scelta
e' solo diagnostica, l'hard-cap gia' protegge).
Test: +8 (4 gate A, 4 diagnostica B). Suite 156/156. Path live pulito
(skh_error/pos_error/eq_fallback = None). Diario 2026-07-01-book-live-error-surfacing.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
401 lines
20 KiB
Python
401 lines
20 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]
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
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) < 1e-6, 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"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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="2026-07-01", 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="2026-07-01", 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="2026-07-01", 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)
|