merge lavoro/goldrake-u2 (revisione 27/09)
This commit is contained in:
@@ -46,7 +46,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import ScalaNonAutorizzata, book_report
|
||||
from src.live.execution import DeribitTrader
|
||||
from src.live.execution import DeribitTrader, RebalanceParziale
|
||||
from src.live.notifier import notify
|
||||
from src.live.venue_probe import diagnose, errori_dal_report
|
||||
|
||||
@@ -85,6 +85,31 @@ def log_event(rec: dict):
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int):
|
||||
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la
|
||||
posizione risultante: un ordine eseguito non deve dipendere dalla lettura della posizione
|
||||
successiva per essere salvato (#23 — un 502 su `position_usd()` dopo un fill riuscito lo
|
||||
faceva sparire dal log, perche' la lettura veniva PRIMA del giro `for f in fills`).
|
||||
|
||||
`pos_after` e' sempre None qui: la posizione dopo il rebalance si legge (best-effort) DOPO
|
||||
aver registrato tutti i fill, in `_run` — vedi il commento li'."""
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=None,
|
||||
tp_frac=tp_frac, skh_sign=skh_sign))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
|
||||
|
||||
def _run():
|
||||
cfg = load_config()
|
||||
want_execute = "--execute" in sys.argv[1:]
|
||||
@@ -129,7 +154,9 @@ def _run():
|
||||
print(f" ultima barra : {r['last_data']}\n")
|
||||
|
||||
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}")
|
||||
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
|
||||
# quel segnale ignoto (modo SOLO PROTEZIONE, sotto) — i target restano informativi.
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}")
|
||||
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
||||
|
||||
# --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) --------------------------
|
||||
@@ -220,11 +247,24 @@ def _run():
|
||||
"azione": "rebuild_history.py --asset BTC ETH"})
|
||||
return
|
||||
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
||||
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
||||
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
|
||||
print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}")
|
||||
if do_execute: # (#21) segnalato: il modo SOLO PROTEZIONE e' sotto
|
||||
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
||||
|
||||
# --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------
|
||||
# Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda
|
||||
# ordini di ribilancio (dimensionare un ordine su un numero che non si conosce e' peggio che
|
||||
# non ordinare) -> ma CONTROLLA LO STESSO il disaster-SL di ogni asset: la protezione dipende
|
||||
# da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia'
|
||||
# bloccando l'esecuzione, qui senza saltare la protezione.
|
||||
solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error")
|
||||
if solo_protezione and do_execute:
|
||||
titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione"
|
||||
if r.get("eq_fallback") else
|
||||
"⚠️ BOOK LIVE — SKH non calcolato: nessun ordine, solo protezione")
|
||||
notify(titolo, {"nota": solo_protezione})
|
||||
|
||||
# Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento
|
||||
# NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il
|
||||
# nuovo dimensionamento accanto, cosi' il messaggio si legge senza aprire il repo.
|
||||
@@ -256,7 +296,12 @@ def _run():
|
||||
sk = a["skh_state"]
|
||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||
order = a["order"]
|
||||
if order is None:
|
||||
if solo_protezione:
|
||||
# (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e'
|
||||
# "non tocco perche' non so quanto vale il conto / qual e' il segnale".
|
||||
motivo_breve = solo_protezione if len(solo_protezione) <= 60 else solo_protezione[:60] + "…"
|
||||
act = f"HOLD (solo protezione: {motivo_breve})"
|
||||
elif order is None:
|
||||
act = "HOLD (a target)"
|
||||
elif order.get("is_close"):
|
||||
act = f"CLOSE ${cur:,.0f}"
|
||||
@@ -268,26 +313,33 @@ def _run():
|
||||
f"| pos ${cur:+,.0f} -> {act}")
|
||||
|
||||
try:
|
||||
if do_execute and order is not None:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
newpos = trader.position_usd(inst)
|
||||
if do_execute and order is not None and not solo_protezione:
|
||||
# U1/U2 si uniscono dopo questo rilascio: `set_open_cap` e' di U1 (execution.py,
|
||||
# stesso file, non toccato qui). hasattr perche' oggi DeribitTrader non lo ha
|
||||
# ancora — l'hasattr si toglie al merge, quando entrambi vivono nello stesso ramo.
|
||||
if hasattr(trader, "set_open_cap"):
|
||||
trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark)
|
||||
try:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
except RebalanceParziale as rp:
|
||||
# (#23) almeno un fill e' passato prima che il resto del passo sollevasse
|
||||
# (es. flip: close ok, open KO) -> si registra quel fill SUBITO (non e' un
|
||||
# fill perso), poi si rilancia la causa nell'handler di isolamento esistente.
|
||||
for f in rp.fills:
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
||||
raise rp.causa
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
||||
# newpos e' un RECONCILE informativo, letto DOPO aver registrato i fill (#23): se
|
||||
# questa lettura solleva, i fill sono gia' salvi (sopra), e non e' un guasto del
|
||||
# giro (isolamento) -> si segnala e si continua (disaster-SL sotto, comunque).
|
||||
try:
|
||||
newpos = trader.position_usd(inst)
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
|
||||
print(f" reconcile: pos illeggibile ({type(e).__name__})")
|
||||
if do_execute:
|
||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||
stato = ds.get("state")
|
||||
@@ -336,7 +388,10 @@ def _run():
|
||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
|
||||
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
|
||||
if scoperti or falliti:
|
||||
# (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha
|
||||
# ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se
|
||||
# il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima.
|
||||
if scoperti or falliti or (solo_protezione and do_execute):
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
|
||||
+46
-23
@@ -36,6 +36,19 @@ class GuardrailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RebalanceParziale(RuntimeError):
|
||||
"""`rebalance_signed` ha eseguito almeno un fill (es. il close di un flip) e POI un passo
|
||||
successivo ha sollevato (es. l'open dall'altro lato). Il fill gia' eseguito NON e' un fill
|
||||
perso: `.fills` li porta al chiamante (che li registra) e `.causa` e' l'eccezione originale
|
||||
(il chiamante la rilancia dentro il suo handler di isolamento, #23)."""
|
||||
|
||||
def __init__(self, fills: list["Fill"], causa: Exception):
|
||||
self.fills = fills
|
||||
self.causa = causa
|
||||
super().__init__(f"rebalance parziale: {len(fills)} fill eseguiti, poi "
|
||||
f"{type(causa).__name__}: {causa}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fill:
|
||||
"""Esito verificato di un ordine reale."""
|
||||
@@ -171,36 +184,46 @@ class DeribitTrader(DeribitRead):
|
||||
- target ~0 -> close();
|
||||
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
||||
Ritorna i Fill eseguiti."""
|
||||
Ritorna i Fill eseguiti.
|
||||
|
||||
⚠️ FILL PERSI (#23). Un flip e' DUE passi (close, poi open dall'altra parte): se il primo
|
||||
va a segno e il secondo solleva, il fill del primo passo NON deve sparire. Da qui in poi
|
||||
un'eccezione con almeno un fill gia' in `fills` diventa `RebalanceParziale(fills, causa)`
|
||||
invece di propagare nuda: il chiamante registra i fill fatti e poi rilancia `causa`."""
|
||||
cur = self.position_usd(instrument)
|
||||
if abs(target_notional_usd - cur) < min_usd:
|
||||
return []
|
||||
fills: list[Fill] = []
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
try:
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
if f:
|
||||
fills.append(f)
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
|
||||
if fills:
|
||||
raise RebalanceParziale(fills, e) from e
|
||||
raise
|
||||
|
||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||
|
||||
+20
-7
@@ -95,15 +95,20 @@ def _positions(client):
|
||||
USDE_INDEX_URL = _usde.index_url()
|
||||
|
||||
|
||||
def _collaterale_usde(client) -> tuple[float, str]:
|
||||
def _collaterale_usde(client) -> tuple[float | None, str]:
|
||||
"""Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che
|
||||
PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in
|
||||
src/live/usde.py — difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC,
|
||||
-24% fantasma dopo la prima conversione."""
|
||||
-24% fantasma dopo la prima conversione.
|
||||
|
||||
⚠️ (#20) Un'eccezione sulla LETTURA del conto USDE non e' "0 USDE": e' "non lo so". Ritorna
|
||||
None (non 0.0) + la nota dell'eccezione -> `_equity` propaga l'ignoto invece di dedurre un
|
||||
equity piu' basso di quello vero. Eq letta con successo e <= 0 resta 0.0 (conto senza USDE:
|
||||
quello e' un fatto accertato, legittimo)."""
|
||||
try:
|
||||
eq = float(client.account_summary("USDE").get("equity") or 0)
|
||||
except Exception:
|
||||
return 0.0, ""
|
||||
except Exception as e:
|
||||
return None, f"USDE illeggibile: {type(e).__name__}: {e}"
|
||||
if eq <= 0:
|
||||
return 0.0, ""
|
||||
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL)
|
||||
@@ -117,6 +122,8 @@ def _equity(client, marks):
|
||||
eq = float(client.account_summary("USDC").get("equity") or 0)
|
||||
if eq > 1:
|
||||
usde_usd, usde_note = _collaterale_usde(client)
|
||||
if usde_usd is None: # (#20) USDE illeggibile -> l'equity e' IGNOTA,
|
||||
return None, usde_note # non "USDC senza USDE": non si assume 0.
|
||||
if usde_usd > 0:
|
||||
return eq + usde_usd, f"mainnet USDC + {usde_note}"
|
||||
return eq, "mainnet USDC"
|
||||
@@ -133,7 +140,8 @@ def _equity(client, marks):
|
||||
if any_ok and tot > 1:
|
||||
return tot, "mainnet coin-margined"
|
||||
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
|
||||
# segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside).
|
||||
# segnalato a book_execute, che in modo SOLO PROTEZIONE non manda alcun rebalance_signed —
|
||||
# controlla comunque il disaster-SL, che dipende da posizione/mark, non da equity (#21).
|
||||
return None, "conto flat / non finanziato"
|
||||
|
||||
|
||||
@@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
eq_basis = ("override" if equity_override is not None
|
||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
||||
# Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del
|
||||
# conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO.
|
||||
# conto reale. SEGNALATO, e book_execute passa a SOLO PROTEZIONE: nessun rebalance_signed (non
|
||||
# si dimensiona un ordine senza sapere quanto vale il conto), ma il disaster-SL si controlla
|
||||
# comunque per ogni asset (#21).
|
||||
eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} "
|
||||
f"invece del conto reale") if (equity_override is None and client is not None
|
||||
and not real_eq) else None
|
||||
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
|
||||
return dict(
|
||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
||||
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
||||
# (#25) TUTTI i mark devono venire da mainnet, non solo BTC: con due asset, un solo
|
||||
# controllo lasciava ETH libero di essere in fallback (close certificata) senza che
|
||||
# `online` lo dicesse -> il gate offline di book_execute non si accorgeva del guasto.
|
||||
online=(client is not None and all(marks_src.get(a) == "mainnet" for a in ASSETS)),
|
||||
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
||||
assets=assets, orders=orders, live_trades=live_trades,
|
||||
|
||||
+21
-6
@@ -535,8 +535,11 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
|
||||
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)."""
|
||||
def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
|
||||
"""(#21, U2) Comportamento cambiato: eq_fallback NON fa piu' proseguire un rebalance sul
|
||||
paper_cap — passa a SOLO PROTEZIONE (nessun ordine, disaster-SL comunque controllato, exit 2).
|
||||
Prima di questa riparazione il giro dimensionava un ordine reale su un capitale fittizio da
|
||||
$2.000 quando non si sapeva quanto valesse il conto vero; ora non manda alcun ordine."""
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
@@ -551,18 +554,30 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
alerts = []
|
||||
|
||||
class FakeDT:
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
|
||||
def position_usd(self, inst): return 0.0
|
||||
|
||||
trader = FakeDT()
|
||||
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, "DeribitTrader", lambda: trader)
|
||||
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()
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
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)
|
||||
assert any("equity fallback" in t for t, _ in alerts) # alert diagnostico esistente
|
||||
assert any("solo protezione" in t.lower() for t, _ in alerts) # NUOVO alert di modo
|
||||
assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato
|
||||
assert uscita == 2 # giro degradato
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Test U2 (gitea:Adriano/PythagorasGoal #20, #21, #22, #23, #25).
|
||||
|
||||
Principio unico: quando il giro non sa QUANTO vale il conto (#20, #21) o QUALE e' il segnale
|
||||
(#22), NON manda ordini di ribilancio, ma CONTROLLA LO STESSO il disaster-SL di ogni asset — la
|
||||
protezione dipende da posizione e mark, non da equity o segnale. E un fill gia' eseguito non e'
|
||||
un fill perso se un passo successivo del rebalance, o la lettura della posizione dopo, falliscono
|
||||
(#23). `online` richiede TUTTI i mark da mainnet, non solo BTC (#25).
|
||||
|
||||
Nessun test tocca la rete: trader ed eccezioni sono finti.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from helpers import carica_script
|
||||
from src.live import shadow as S
|
||||
from src.live.execution import DeribitTrader, Fill, RebalanceParziale
|
||||
|
||||
|
||||
def _fresh_bar() -> str:
|
||||
import pandas as _pd
|
||||
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #20 — USDE illeggibile: l'equity diventa IGNOTA (None), non 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _ClientUSDEBoom:
|
||||
def __init__(self, usdc=2056.0):
|
||||
self._usdc = usdc
|
||||
|
||||
def account_summary(self, cur):
|
||||
if cur == "USDE":
|
||||
raise RuntimeError("gateway giu'")
|
||||
return {"equity": self._usdc}
|
||||
|
||||
|
||||
def test_usde_illeggibile_rende_equity_ignota_non_zero():
|
||||
eq, src = S._equity(_ClientUSDEBoom(), {})
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
|
||||
def test_watermark_non_si_scrive_su_equity_ignota(tmp_path, monkeypatch):
|
||||
"""`write_equity_watermark` riceve None quando l'equity e' ignota: non deve scrivere il
|
||||
file (gia' cosi' nel codice esistente — qui lo si blinda con un test, come chiesto)."""
|
||||
import src.live.book as book
|
||||
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
|
||||
assert book.write_equity_watermark(None) is None
|
||||
assert not (tmp_path / "equity_seen.json").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #25 — online richiede TUTTI i mark mainnet, non solo BTC.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_online_richiede_tutti_i_mark_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0},
|
||||
{"BTC": "mainnet", "ETH": "fallback close (RuntimeError)"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
r = S.shadow_report()
|
||||
assert r["online"] is False # prima di #25 sarebbe stato True (guardava solo BTC)
|
||||
|
||||
|
||||
def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0}, {"BTC": "mainnet", "ETH": "mainnet"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
assert S.shadow_report()["online"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #21, #22 — SOLO PROTEZIONE: equity ignota o SKH ignoto -> nessun rebalance_signed, ma il
|
||||
# disaster-SL si controlla LO STESSO per ogni asset. Giro degradato -> exit 2 (se ARMATO).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _asset(nome, inst, mark, order=None):
|
||||
return dict(asset=nome, instrument=inst, tp_frac=1.0, skh_sign=1, skh_state="flat",
|
||||
net_target=225.0, position_usd=0.0, mark=mark, order=order)
|
||||
|
||||
|
||||
def _canned_report(**overrides):
|
||||
base = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
|
||||
eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None,
|
||||
eq_fallback=None,
|
||||
assets=[
|
||||
_asset("BTC", "BTC_USDC-PERPETUAL", 60000.0,
|
||||
order=dict(side="buy", delta=225.0, is_close=False, needs_flip=False)),
|
||||
_asset("ETH", "ETH_USDC-PERPETUAL", 3000.0, order=None),
|
||||
],
|
||||
orders=[],
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class _TraderSoloProtezione:
|
||||
"""Trader finto: registra se rebalance_signed viene chiamato (NON deve) e quali asset
|
||||
ricevono ensure_disaster_sl (TUTTI, comunque)."""
|
||||
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
|
||||
def _run_solo_protezione(canned, monkeypatch):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
trader = _TraderSoloProtezione()
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
return trader, alerts, uscita
|
||||
|
||||
|
||||
def test_eq_fallback_e_solo_protezione(monkeypatch):
|
||||
canned = _canned_report(
|
||||
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000")
|
||||
trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch)
|
||||
assert trader.rebalance_chiamato is False # nessun ordine dimensionato
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI
|
||||
assert any("solo protezione" in t.lower() for t, _ in alerts)
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_skh_error_e_solo_protezione(monkeypatch):
|
||||
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
|
||||
trader, alerts, uscita = _run_solo_protezione(canned, monkeypatch)
|
||||
assert trader.rebalance_chiamato is False
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"]
|
||||
assert any("solo protezione" in t.lower() for t, _ in alerts)
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_solo_protezione_in_dry_run_non_esce_2(monkeypatch, capsys):
|
||||
"""Un'anteprima (senza execution_enabled) non ha tentato nulla: non e' un giro degradato."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
canned = _canned_report(eq_fallback="equity reale non leggibile -> sizing su paper_cap")
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=False, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute
|
||||
mod._run() # non deve sollevare
|
||||
assert "solo protezione" in capsys.readouterr().out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #23 — fill persi: un fill gia' eseguito non sparisce se un passo successivo del rebalance
|
||||
# (flip) o la lettura della posizione dopo falliscono.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeExec(DeribitTrader):
|
||||
"""DeribitTrader senza rete: instrumenta close/open per il test del flip parziale."""
|
||||
|
||||
def __init__(self, pos_usd, open_boom=None, chiusura_fill=None):
|
||||
self._pos = pos_usd
|
||||
self._open_boom = open_boom
|
||||
self._chiusura_fill = chiusura_fill
|
||||
|
||||
def position_usd(self, instrument):
|
||||
return self._pos
|
||||
|
||||
def close(self, instrument, label="tp01-close"):
|
||||
return self._chiusura_fill
|
||||
|
||||
def open(self, instrument, side, amount, label="tp01-open"):
|
||||
if self._open_boom is not None:
|
||||
raise self._open_boom
|
||||
return Fill(instrument, side, amount, amount, 60000.0, 0.01, "oid", "filled", True, "")
|
||||
|
||||
|
||||
def test_flip_con_open_che_esplode_dopo_close_solleva_rebalance_parziale():
|
||||
"""execution.py (#23a): il close del flip e' andato, l'open dall'altra parte no -> il fill
|
||||
del close NON deve sparire dentro un'eccezione nuda."""
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
t = _FakeExec(pos_usd=120.0, open_boom=RuntimeError("502 Bad Gateway"), chiusura_fill=close_fill)
|
||||
with pytest.raises(RebalanceParziale) as ei:
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", -80.0, 60000.0, min_usd=5.0)
|
||||
assert ei.value.fills == [close_fill]
|
||||
assert isinstance(ei.value.causa, RuntimeError) and "502" in str(ei.value.causa)
|
||||
|
||||
|
||||
def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): su RebalanceParziale, il fill del close finisce nel jsonl e nella
|
||||
riga di log PRIMA che la causa venga rilanciata; l'asset risulta fallito, l'altro asset
|
||||
(ETH) resta elaborato normalmente."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderFlip()
|
||||
canned = _canned_report() # BTC ha un ordine, ETH order=None
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert uscita == 2
|
||||
assert "-> SELL 0.0020 @ $60,000.0" in out # riga di fill stampata
|
||||
assert "asset falliti in questo giro: BTC" in out # isolamento esistente
|
||||
assert trader.visti_sl == ["ETH_USDC-PERPETUAL"] # BTC salta l'SL questo giro
|
||||
# (il fill del close NON e' un fill perso: e' nel jsonl anche se il resto del flip e' fallito)
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["side"] == "sell" and righe[0]["filled"] == 0.002
|
||||
assert righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): rebalance_signed va a segno, ma `position_usd` (reconcile) solleva
|
||||
dopo -> il fill e' comunque registrato con pos_after None, e non e' un guasto del giro."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderReconcileBoom:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
raise RuntimeError("api 500")
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderReconcileBoom()
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run() # non deve sollevare: il reconcile mancato non e' fatale
|
||||
out = capsys.readouterr().out
|
||||
assert "reconcile: pos illeggibile (RuntimeError)" in out
|
||||
assert "asset falliti" not in out # NON e' trattato come giro fallito
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione comunque
|
||||
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Il formato delle righe stampate non e' cambiato: tradesdb.parse_cron_log deve continuare a
|
||||
# leggerlo, sia in un giro normale sia in un giro con RebalanceParziale (#23).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill_btc = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderOk:
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill_btc] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 180.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T13:47:01Z cron_book =====\n{out}===== done 13:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "buy" and abs(f.qty - 0.003) < 1e-9 and f.verified == 1
|
||||
|
||||
|
||||
def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderFlip())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit:
|
||||
pass
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T14:47:01Z cron_book =====\n{out}===== done 14:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "sell" and abs(f.qty - 0.002) < 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_open_cap (da U1, stesso rilascio): chiamato SOLO se il trader lo espone (hasattr — U1 e
|
||||
# U2 si uniscono dopo questo rilascio), subito prima di rebalance_signed.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderConCap:
|
||||
def __init__(self):
|
||||
self.cap_visti = []
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
self.cap_visti.append((inst, cap_usd, mark))
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderConCap()
|
||||
canned = _canned_report(cap_per_asset=300.0, scala=1.25)
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
assert trader.cap_visti == [("BTC_USDC-PERPETUAL", 300.0 * 1.25, 60000.0)] # solo BTC ha un ordine
|
||||
|
||||
|
||||
def test_set_open_cap_non_richiesto_se_il_trader_non_lo_espone(monkeypatch):
|
||||
"""hasattr: un trader senza set_open_cap (oggi, prima del merge con U1) non deve rompersi."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderSenzaCap:
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
mod._run() # non deve sollevare AttributeError
|
||||
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
|
||||
assert "non leggibile" in src
|
||||
|
||||
|
||||
def test_gateway_usde_giu_non_rompe_la_lettura_usdc():
|
||||
def test_gateway_usde_giu_rende_lequity_IGNOTA_non_solo_usdc():
|
||||
"""(#20) Comportamento cambiato: un'eccezione sulla lettura del conto USDE NON vale 0 USDE.
|
||||
Prima di questa riparazione, `eq` restava $2056 (solo USDC) come se l'USDE non ci fosse:
|
||||
se sul conto c'era davvero collaterale USDE, quel $2056 era un equity sottostimato spacciato
|
||||
per certo. Ora l'intera equity e' IGNOTA (None) — coerente col trattamento di ogni altra
|
||||
lettura fallita nel modulo (posizione, mark): non si assume, si segnala."""
|
||||
eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {})
|
||||
assert eq == 2056.0 and src == "mainnet USDC"
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
@@ -200,8 +200,12 @@ def test_l_esecutore_non_data_piu_i_fill_alla_barra_di_segnale():
|
||||
"""Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`.
|
||||
|
||||
Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte.
|
||||
Dal refactor U2 (#23) la registrazione di un fill vive in `_registra_fill(...)`, che riceve
|
||||
`bar_ts` come parametro invece di leggere `r['last_data']` alla riga del log_event: il call
|
||||
site passa `bar_ts=r["last_data"]`.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text()
|
||||
assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src
|
||||
assert "bar_ts=str(pd.Timestamp(r['last_data']))" in src
|
||||
assert "bar_ts=str(pd.Timestamp(bar_ts))" in src # dentro _registra_fill
|
||||
assert 'bar_ts=r["last_data"]' in src # il call site passa la barra
|
||||
assert "ts_utc=datetime.now(timezone.utc)" in src
|
||||
|
||||
Reference in New Issue
Block a user