ae887a3d98
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
640 lines
29 KiB
Python
640 lines
29 KiB
Python
"""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 MAX_AMOUNT, 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, capsys):
|
|
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
|
|
out = capsys.readouterr().out
|
|
return trader, alerts, uscita, out
|
|
|
|
|
|
def test_eq_fallback_e_solo_protezione(monkeypatch, capsys):
|
|
canned = _canned_report(
|
|
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000")
|
|
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
|
assert trader.rebalance_chiamato is False # nessun ordine dimensionato
|
|
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI
|
|
# (#21) UNA sola notify per il guasto, non due: il vecchio testo diceva "sizing su paper_cap",
|
|
# falso in modo SOLO PROTEZIONE (nessun ordine dimensionato affatto).
|
|
assert len(alerts) == 1
|
|
assert "solo protezione" in alerts[0][0].lower()
|
|
assert "paper_cap" not in alerts[0][0]
|
|
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out # riepilogo finale corretto
|
|
assert uscita == 2
|
|
|
|
|
|
def test_skh_error_e_solo_protezione(monkeypatch, capsys):
|
|
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
|
|
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
|
assert trader.rebalance_chiamato is False
|
|
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"]
|
|
# (#22) UNA sola notify, non due ("SKH feed fallito" + "SKH non calcolato").
|
|
assert len(alerts) == 1
|
|
assert "solo protezione" in alerts[0][0].lower()
|
|
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out
|
|
assert uscita == 2
|
|
|
|
|
|
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 set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
if inst.startswith("BTC"):
|
|
raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close"))
|
|
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 set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
return [fill] if inst.startswith("BTC") else []
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #39 — regressione di #23: pos_after finiva sempre None nel jsonl, anche quando il reconcile
|
|
# leggeva la posizione senza problemi (la lettura era rimasta un side-effect informativo, il suo
|
|
# risultato non arrivava piu' a `_registra_fill`).
|
|
# ---------------------------------------------------------------------------
|
|
def test_rebalance_ok_scrive_pos_after_dal_reconcile(tmp_path, monkeypatch, capsys):
|
|
"""book_execute (#39): rebalance_signed e position_usd vanno a segno entrambi -> il record
|
|
di log_event ha pos_after = il valore letto dal reconcile, non None."""
|
|
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 _TraderOk:
|
|
def set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
return [fill] if inst.startswith("BTC") else []
|
|
|
|
def position_usd(self, inst):
|
|
return 500.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
|
|
assert "reconcile: pos $500" in out
|
|
|
|
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"] == 500.0
|
|
|
|
|
|
def test_position_usd_che_solleva_non_impedisce_il_fill_pos_after_none(tmp_path, monkeypatch, capsys):
|
|
"""book_execute (#39): stesso scenario di sopra ma il reconcile solleva -> il fill si
|
|
registra comunque (vincolo #23), con pos_after None."""
|
|
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 set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
return [fill] if inst.startswith("BTC") else []
|
|
|
|
def position_usd(self, inst):
|
|
raise RuntimeError("api 500")
|
|
|
|
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: _TraderReconcileBoom())
|
|
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 fill non e' perso
|
|
out = capsys.readouterr().out
|
|
assert "reconcile: pos illeggibile (RuntimeError)" in out
|
|
|
|
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]["filled"] == 0.003
|
|
assert 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 set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
return [fill_btc] if inst.startswith("BTC") else []
|
|
|
|
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 set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
if inst.startswith("BTC"):
|
|
raise RebalanceParziale([close_fill], RuntimeError("open KO"))
|
|
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 (#18): chiamato SEMPRE, diretto (l'hasattr era solo per il periodo prima del
|
|
# merge con U1, in cui DeribitTrader non lo aveva ancora — tolto qui).
|
|
# ---------------------------------------------------------------------------
|
|
def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
|
|
mod = carica_script("scripts/live/book_execute.py")
|
|
|
|
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_trader_senza_set_open_cap_fallisce_isolato_non_lascia_morire_il_giro(monkeypatch, capsys):
|
|
"""Post-merge: `set_open_cap` si chiama diretto, senza hasattr (era solo transitorio prima
|
|
del merge con U1). Un trader che non lo espone ora fa fallire QUELL'asset (isolamento per
|
|
asset, gia' esistente) — non l'intero giro: ETH resta elaborato normalmente."""
|
|
mod = carica_script("scripts/live/book_execute.py")
|
|
|
|
class _TraderSenzaCap:
|
|
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() # BTC ha un ordine (set_open_cap si chiama), ETH order=None
|
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
|
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
|
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap())
|
|
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 "AttributeError" in out # isolato: si vede nel log, non muore il giro
|
|
assert "asset falliti in questo giro: BTC" in out
|
|
assert uscita == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #24 — ensure_disaster_sl "unknown" in book_execute: NON e' "naked" (non si e' tolto nulla),
|
|
# ma il giro resta degradato -> notify dedicata e exit 2.
|
|
# ---------------------------------------------------------------------------
|
|
def test_ensure_disaster_sl_unknown_notifica_e_degrada_il_giro(monkeypatch, capsys):
|
|
mod = carica_script("scripts/live/book_execute.py")
|
|
|
|
class _TraderUnknown:
|
|
def set_open_cap(self, inst, cap_usd, mark):
|
|
pass
|
|
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
|
return []
|
|
def position_usd(self, inst):
|
|
return 0.0
|
|
def ensure_disaster_sl(self, inst, sl_pct):
|
|
if inst.startswith("ETH"):
|
|
return {"state": "unknown", "notes": "RuntimeError: 502 su get_positions"}
|
|
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
|
|
|
trader = _TraderUnknown()
|
|
canned = _canned_report() # BTC ha un ordine, ETH order=None -> ETH e' l'asset "unknown"
|
|
alerts = []
|
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
|
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
|
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
|
monkeypatch.setattr(mod, "load_config",
|
|
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
|
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
|
skh_feed_max_age_min=30.0))
|
|
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
|
|
|
uscita = None
|
|
try:
|
|
mod._run()
|
|
except SystemExit as e:
|
|
uscita = e.code
|
|
out = capsys.readouterr().out
|
|
|
|
assert uscita == 2
|
|
assert "ETH: protezione NON VERIFICATA" in out
|
|
notifiche = [d for t, d in alerts if "non verificabile" in t.lower()]
|
|
assert len(notifiche) == 1
|
|
assert notifiche[0]["asset"] == "ETH"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# execution.py::_submit (#21) — legge il tetto con getattr: un DeribitTrader creato senza
|
|
# passare da __init__ (object.__new__, come qui) non deve rompersi con AttributeError perche'
|
|
# gli manca l'attributo _open_cap.
|
|
# ---------------------------------------------------------------------------
|
|
def test_submit_su_trader_senza_open_cap_non_solleva_attributeerror(monkeypatch):
|
|
t = object.__new__(DeribitTrader) # bypassa __init__: niente self._open_cap
|
|
monkeypatch.setattr(t, "_post", lambda path, payload: {
|
|
"order": {"order_id": "1", "order_state": "filled",
|
|
"filled_amount": payload["amount"]},
|
|
"trades": [{"price": 1.0, "amount": payload["amount"], "fee": 0.0}]})
|
|
dentro = MAX_AMOUNT["ETH_USDC-PERPETUAL"] * 0.5
|
|
f = t._submit("ETH_USDC-PERPETUAL", "buy", dentro, reduce_only=False, label="book-open")
|
|
assert f.verified is True # arrivato in fondo: niente AttributeError sul cap
|