feat(live): arma il BOOK DERIBIT (TP01+SKH01 nettati in software)

Estende l'esecuzione live da TP01-only al book Deribit-only completo. TP01 e SKH01
tradano lo STESSO strumento (una sola posizione netta per conto su Deribit) -> netting
in software: un solo ordine/asset verso il target netto.

- src/live/book.py: target NETTO per asset = clamp(0.5*E*(0.75*tp_frac + 0.25*skh_sign), ±cap).
  Riusa current_target (TP01, causale) + _skyhook_positions (segno L/S, book 230m) + conto reale.
- src/live/execution.py: rebalance_signed() — reconcile CON SEGNO (long/short, flip via close+open,
  reduce reduce_only). La close resta sempre permessa (si esce da qualunque posizione).
- src/live/livefeed.py: fresh_5m() — feed 5m certificato + coda recente EFFIMERA da Deribit pubblico
  (stesso simbolo inverse, in-memory, NON scrive su disco -> dati certificati intatti; fallback al
  certificato su errore). Solo SKH01 ne ha bisogno (e' a 230m); TP01 e' giornaliero.
- scripts/live/book_execute.py: executor doppio-gate (config + --execute), disaster-SL on-book sulla
  posizione netta, log in data/live/book_executions.jsonl. Feed SKH fresco (live_feed=True).
- scripts/cron_book.sh + crontab ORARIO: book idempotente ogni ora (riconcilia al netto corrente);
  rimossa la riga live_execute.py (TP01-only) dal cron daily per non far collidere i due.
- config/live.json: ARMATO (execution_enabled=true). cap/asset $300 split 75/25, disaster-SL -30%.
  Tutto flat all'arming -> nessun ordine finche' un segnale non arma.
- tests/test_book_live.py: 20 test (sizing 75/25, cap, flip/close/reduce, parita' pesi backtest,
  gate-safety, reconcile con trader fittizio, merge/dedup feed + fallback, loader onorato). 76/76 pass.

CAVEAT: exit SKH SOFTWARE (latenza fino a fine barra 230m; solo disaster-SL on-book); TP01 scende a
peso 0.75 (max $225/asset); SKH01 resta research-grade (equity daily-step, margine DD ETH sottile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-23 21:43:27 +00:00
parent 25a22fc7c1
commit db738bce3b
9 changed files with 665 additions and 5 deletions
+230
View File
@@ -0,0 +1,230 @@
"""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"])