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
+140
View File
@@ -0,0 +1,140 @@
"""BOOK DERIBIT-ONLY (TP01 + SKH01) — target NETTO per asset via NETTING SOFTWARE su un solo conto.
TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit esiste UNA sola
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
WEIGHT = 0.5 (book 50/50 BTC+ETH, come i due sleeve)
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
E = equity reale del conto (fallback paper se offline)
CAP = max_notional_per_asset_usd (config/live.json, $300)
GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars +
non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0
-> il target netto si aggiorna -> il reconciler chiude la quota SKH. NB: gli exit sono SOFTWARE
(no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01) -> latenza fino alla chiusura
della barra 230m corrente. Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA.
Questo modulo NON invia nulla: costruisce solo il report/ordine. L'invio è in scripts/live/book_execute.py
(doppio gate). Causale: usa solo barre chiuse (eredita la causalità di TP01 e di _skyhook_positions).
"""
from __future__ import annotations
import json
from pathlib import Path
from src.live.deribit import INSTRUMENT, notional_to_amount
from src.live.shadow import ASSETS, WEIGHT, shadow_report
from src.portfolio.sleeves import _skyhook_positions
PROJECT_ROOT = Path(__file__).resolve().parents[2]
CONFIG = PROJECT_ROOT / "config" / "live.json"
# Pesi del book Deribit-only (vedi src/portfolio/sleeves.deribit_book_sleeves).
W_TP01 = 0.75
W_SKH = 0.25
FLAT_USD = 1.0
def _cap() -> float:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
return float(cfg.get("max_notional_per_asset_usd", 300.0))
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
weight: float = WEIGHT) -> float:
"""Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile.
Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25),
clampata al cap per-asset. Vedi formula nel docstring del modulo."""
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
return max(-cap, min(cap, raw))
def _skh_sign(state) -> int:
if state == "flat" or not isinstance(state, dict):
return 0
return 1 if state.get("dir") == "LONG" else -1
def build_book_order(instrument: str, net_target_usd: float, current_pos_usd: float,
mark: float | None, min_usd: float = 5.0) -> dict | None:
"""COSTRUISCE (non invia) l'ordine per portare la posizione al target NETTO. Ritorna dict-ordine
o None se sotto-soglia. Gestisce long/short e i flip:
- |delta| < min_usd -> None (già a target);
- target ~0 -> CLOSE (reduce_only, esce sempre);
- flip di segno (long<->short) -> needs_flip=True (close + open, gestito dall'executor);
- stesso segno, |target|<|cur| -> REDUCE (reduce_only);
- altrimenti -> OPEN/INCREASE (buy se delta>0, sell se delta<0)."""
delta = net_target_usd - current_pos_usd
if abs(delta) < min_usd:
return None
side = "buy" if delta > 0 else "sell"
is_close = abs(net_target_usd) < FLAT_USD and abs(current_pos_usd) > FLAT_USD
needs_flip = (current_pos_usd > FLAT_USD and net_target_usd < -FLAT_USD) or \
(current_pos_usd < -FLAT_USD and net_target_usd > FLAT_USD)
same_sign = (net_target_usd > 0) == (current_pos_usd > 0)
is_reduce = is_close or (same_sign and abs(net_target_usd) < abs(current_pos_usd) and not needs_flip)
amount = notional_to_amount(instrument, abs(delta), price=mark)
if amount == 0.0 and not is_close:
return None
return dict(
instrument=instrument, side=side, amount=amount, type="market",
reduce_only=bool(is_reduce or is_close), needs_flip=bool(needs_flip), is_close=bool(is_close),
net_target=round(net_target_usd, 2), current=round(current_pos_usd, 2), delta=round(delta, 2),
)
def book_report(offline: bool = False, equity_override: float | None = None,
live_feed: bool = False) -> dict:
"""Stato completo del book (TP01+SKH01) NETTO, per asset. NON invia nulla. Serializzabile.
Riusa shadow_report (target TP01 + conto/posizioni/mark reali) e ci somma il segno di SKH01.
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
sh = shadow_report(offline=offline, equity_override=equity_override)
cap = _cap()
equity = sh["equity"]
load5m = None
if live_feed:
from src.live.livefeed import fresh_5m
load5m = fresh_5m
try:
skh = _skyhook_positions(load5m=load5m)
except Exception as e: # non bloccare il report se il feed SKH fallisce
skh = {a: "flat" for a in ASSETS}
sh = {**sh, "skh_error": f"{type(e).__name__}: {e}"}
assets, orders = [], []
for a_rec in sh["assets"]:
a = a_rec["asset"]
inst = INSTRUMENT[a]
tp_frac = float(a_rec["target"])
st = skh.get(a, "flat")
sign = _skh_sign(st)
net = book_net_target(tp_frac, sign, equity, cap)
cur = float(a_rec["position_usd"])
mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
if order:
orders.append(order)
assets.append(dict(
asset=a, instrument=inst,
tp_frac=round(tp_frac, 4), skh_sign=sign,
skh_state=(st if st == "flat" else {k: st[k] for k in ("dir", "entry", "sl", "tp", "bars_in", "max_bars") if k in st}),
net_target=round(net, 2), position_usd=round(cur, 2), mark=mark, mark_src=a_rec.get("mark_src"),
order=order,
))
return dict(
last_data=sh["last_data"], online=sh["online"],
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
assets=assets, orders=orders,
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
)
+41
View File
@@ -140,6 +140,47 @@ class DeribitTrader(DeribitRead):
return [self.open(instrument, "buy", amount, label="tp01-buy")]
return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")]
# --- RIBILANCIO al target CON SEGNO (book TP01+SKH01: long / short / flip) ---
def rebalance_signed(self, instrument: str, target_notional_usd: float, mark: float,
min_usd: float = 5.0) -> list[Fill]:
"""Porta la posizione su `instrument` al target NETTO con SEGNO (long-short, a differenza di
rebalance_to che e' long-only). Gestisce i flip chiudendo prima e riaprendo dall'altro lato.
- |delta| < min_usd -> niente;
- flip di segno -> close() (sempre permessa) poi apre dall'altra parte;
- target ~0 -> close();
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
Ritorna i Fill eseguiti."""
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")
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
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
stop_price: float, label: str = "disaster-sl") -> Fill:
+83
View File
@@ -0,0 +1,83 @@
"""FEED LIVE EFFIMERO per il segnale SKH01 (book a 230m) — NON tocca i dati certificati su disco.
SKH01 decide su griglia 230m: per eseguirlo fedelmente il segnale serve fresco all'ultima barra
chiusa. Il rebuild certificato (rebuild_history.py) gira 1×/giorno e fa un rebuild COMPLETO (pesante):
girarlo ogni ora sarebbe sbagliato e violerebbe la regola "aggiornare lo storico SOLO con
rebuild_history + certificare". Quindi qui NON scriviamo su disco: carichiamo il 5m CERTIFICATO e gli
appendiamo IN MEMORIA una coda recente presa da Deribit PUBBLICO (ccxt, tokenless, STESSO simbolo
inverse del feed certificato -> prezzi entro ~3 bps). I dati certificati restano la verità su disco;
questa estensione vive solo nel processo live e per il calcolo del segnale.
Robusto ai fallimenti: qualunque errore di rete/fetch -> ritorna il feed certificato invariato (il
runner degrada a "fermo all'ultimo dato certificato", mai opera a cieco). Solo SKH01 ne ha bisogno:
TP01 è giornaliero e gira bene sul feed certificato.
"""
from __future__ import annotations
import time
import pandas as pd
from src.data.downloader import load_data
# STESSO simbolo del feed certificato (vedi scripts/analysis/rebuild_history.DERIBIT_INSTR):
# inverse USD perp, storia lunga, entro ~3 bps dal lineare USDC su cui eseguiamo.
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
import ccxt
ex = ccxt.deribit({"enableRateLimit": True})
tf_ms = 5 * 60 * 1000
since = int((time.time() - lookback_days * 86400) * 1000)
rows: dict[int, list] = {}
guard = 0
while guard < 200:
guard += 1
try:
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
except Exception:
break
r = [x for x in r if int(x[0]) >= since]
if not r:
break
for x in r:
t = int(x[0])
rows[t] = [t, float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5] or 0)]
nxt = int(r[-1][0]) + tf_ms
if nxt <= since:
break
since = nxt
if not rows:
return pd.DataFrame(columns=SCHEMA)
return pd.DataFrame(rows.values(), columns=SCHEMA).sort_values("timestamp").reset_index(drop=True)
def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame:
"""Fonde la coda fresca sul feed certificato: concat, dedup per timestamp (la coda VINCE sui
duplicati, ma le barre certificate storiche restano), riordina. Mantiene lo schema di load_data
(inclusa 'datetime' se presente). PURA, testabile senza rete."""
if tail is None or tail.empty:
return base
cols = [c for c in SCHEMA if c in base.columns]
t = tail[[c for c in SCHEMA if c in tail.columns]].copy()
merged = pd.concat([base[cols], t], ignore_index=True)
merged = merged.drop_duplicates("timestamp", keep="last").sort_values("timestamp").reset_index(drop=True)
# ricostruisci 'datetime' coerente (build_frames non la usa, ma load_data la espone)
merged["datetime"] = pd.to_datetime(merged["timestamp"], unit="ms", utc=True)
return merged
def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame:
"""Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore."""
base = load_data(asset, "5m")
sym = DERIBIT_SYMBOL.get(asset)
if sym is None:
return base
try:
tail = _fetch_recent_5m(sym, lookback_days)
except Exception:
return base
return merge_tail(base, tail)
+7 -3
View File
@@ -236,13 +236,17 @@ def _skyhook_returns() -> pd.Series:
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
def _skyhook_positions() -> dict:
def _skyhook_positions(load5m=None) -> dict:
"""Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA
-> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse."""
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse.
load5m: callable(asset)->df5 opzionale (per il live: feed certificato + coda fresca effimera,
vedi src/live/livefeed.fresh_5m). Default = feed certificato su disco (load_data)."""
_load = load5m if load5m is not None else (lambda a: load_data(a, "5m"))
out = {}
for a in ASSETS:
ltf, htf = build_frames(load_data(a, "5m"))
ltf, htf = build_frames(_load(a))
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values
n = len(ltf); i = 0; open_pos = "flat"