7d64dd4c2b
Il 29/07 il feed 5m di SKH01 e' ricaduto sul certificato in 6 giri orari su 8 (eta' 265->685 min, +60 a ogni giro = firma esatta del fallback): latenza d'uscita da ~1h a ~11h, book flat, nessuna posizione esposta. L'allerta del 26/07 ha segnalato 6/6, poi ha stampato un perche' che non aveva misurato — la nota "fetch pubblico KO" era cablata, identica in ogni caso, compreso quello in cui la coda fresca E' attaccata e il vecchio e' il certificato. E la causa vera non era recuperabile per costruzione: _fetch_recent_5m ingoia l'eccezione di pagina con un break e a prima pagina fallita ritorna un frame vuoto, indistinguibile da "il venue non ha barre". Cablato: livefeed.last_fetch_error() (registra E logga nel punto in cui l'errore viene ingoiato) -> book_report.skh_feed_errors -> allerta con la causa. Stesso buco chiuso sul ramo gemello "conto offline", che la ragione l'aveva gia' in mark_src e non la stampava mai. La causa del 29/07 resta IGNOTA e va citata cosi': una prima stesura la attribuiva a un rate limit per-IP come se fosse un fatto -> rimossa, sarebbe stato lo stesso difetto che stavo correggendo scritto meglio. Cron spostato al minuto :07 come ripiego da UNA osservazione, dichiarato tale. Test 11 -> 16 (incluso il caso a meta' paginazione: coda parziale attaccata, mancano le barre PIU' recenti). Book/pesi/config/strategia INVARIATI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
263 lines
14 KiB
Python
263 lines
14 KiB
Python
"""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 = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE
|
|
DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la
|
|
frazione e' presente in config -> il cap cresce col capitale e non strozza il
|
|
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
|
|
fidata; su fallback/offline si torna al cap fisso (protezione downside).
|
|
|
|
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. Gli exit sono SOFTWARE (no
|
|
bracket on-book per SKH, sennò chiuderebbero anche la quota TP01). Solo il disaster-SL (-30%) resta
|
|
on-book, sulla posizione NETTA.
|
|
|
|
⚠️ CORREZIONE 2026-07-26 — questo docstring diceva "latenza fino alla chiusura della barra 230m
|
|
corrente". E' FALSO, e l'errore e' costato tre settimane di misure sbagliate (audit 02/07, follow-up
|
|
24/07): `resample_5m` NON scarta il bin 230m in corso, e il loop di `_skyhook_positions` ci itera
|
|
dentro usando il suo high/low CORRENTE -> il tocco di SL/TP e' visto **entro il primo cron dopo il
|
|
tocco (~1h)**, non a fine barra (mediana 115 min, p90 210 di ritardo in piu'). Misurato: modellare
|
|
il live come "detection a fine barra" (lente `hourly`) lo SOTTOSTIMA di +0.081 di Sharpe FULL sul
|
|
book, in 23/23 offset. Diario `2026-07-26-t1-esecuzione-skh-live.md`.
|
|
La latenza reale dipende quindi dalla FRESCHEZZA del feed 5m, non dalla griglia 230m -> vedi
|
|
`skh_feed_age_min` nel report e l'allerta in scripts/live/book_execute.py.
|
|
|
|
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
|
|
|
|
import pandas as pd
|
|
|
|
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
|
|
|
|
|
|
EQUITY_WATERMARK = PROJECT_ROOT / "data" / "live" / "equity_seen.json"
|
|
# Se non si e' MAI vista l'equity reale non si sa niente del conto: si usa la taglia storicamente
|
|
# sicura, non il cap di config (che puo' essere stato alzato in previsione di un deposito).
|
|
CAP_UNKNOWN_USD = 300.0
|
|
|
|
|
|
def _read_watermark() -> float | None:
|
|
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
|
|
try:
|
|
v = float(json.loads(EQUITY_WATERMARK.read_text())["real_equity"])
|
|
return v if v > 0 else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# Variazione di equity fra due letture consecutive oltre la quale si avvisa. Il book ha vol
|
|
# giornaliera ~0.4%: un salto del 10% fra due giri orari non puo' venire dal trading, quindi e'
|
|
# un deposito, un prelievo, o qualcosa che va guardato SUBITO.
|
|
EQUITY_JUMP_ALERT = 0.10
|
|
|
|
|
|
def write_equity_watermark(real_equity: float | None) -> dict | None:
|
|
"""Registra l'equity reale ogni volta che e' leggibile. Chiamata da `book_report`.
|
|
|
|
Ritorna un dict {prev, new, pct} se il salto rispetto alla lettura precedente supera
|
|
`EQUITY_JUMP_ALERT`, altrimenti None. Serve a due cose in una: **confermare** che un
|
|
versamento e' atterrato e che il sizing lo ha seguito, e **segnalare** un movimento che
|
|
l'operatore non ha causato (prelievo non richiesto, perdita anomala).
|
|
"""
|
|
if real_equity is None or real_equity <= 0:
|
|
return None
|
|
prev = _read_watermark()
|
|
try:
|
|
EQUITY_WATERMARK.parent.mkdir(parents=True, exist_ok=True)
|
|
EQUITY_WATERMARK.write_text(json.dumps(
|
|
{"real_equity": float(real_equity), "ts": pd.Timestamp.now(tz="UTC").isoformat()}))
|
|
except Exception:
|
|
pass # il watermark e' un'ottimizzazione di sicurezza, non un requisito
|
|
if prev is None or prev <= 0:
|
|
return None # prima lettura in assoluto: non c'e' un "prima" da confrontare
|
|
pct = (float(real_equity) - prev) / prev
|
|
if abs(pct) < EQUITY_JUMP_ALERT:
|
|
return None
|
|
return {"prev": prev, "new": float(real_equity), "pct": pct}
|
|
|
|
|
|
def _cap(equity: float | None = None, real_equity: float | None = None,
|
|
eq_fallback: str | None = None) -> float:
|
|
"""Cap notional per-asset ($). Se in config c'e' `max_notional_per_asset_frac` (opt-in), il cap
|
|
diventa DINAMICO = equity * frac (es. equity/2) -> cresce col capitale, cosi' un deposito non
|
|
resta strozzato (vedi frontiera 2026-07-03). GUARDRAIL: il cap dinamico si usa SOLO con equity
|
|
reale fidata (letta dal conto).
|
|
|
|
⚠️ FALLBACK (2026-07-26). Quando l'equity reale NON e' leggibile il sizing ripiega su
|
|
`paper_cap` (=$2.000 nominali), che NON e' il conto vero: il cap fisso e' li' a impedire che
|
|
quel nominale diventi leva reale. Un cap fisso mantenuto A MANO e' pero' sbagliato in entrambi
|
|
i versi — troppo basso dopo un deposito (a $6k strozzava il book al ~10% del target), troppo
|
|
alto prima (a $597 un cap da $3.000 avrebbe permesso $2.000 di nozionale lordo = **3.35x di
|
|
leva** proprio nel momento in cui non si sa quanto c'e' sul conto).
|
|
Quindi il fallback e' legato all'**ultima equity reale osservata**:
|
|
cap_fallback = min(cap_fisso_di_config, watermark * frac)
|
|
Cosi' il cap di config resta un TETTO dichiarato e il watermark impedisce di superare il conto
|
|
vero, in ogni ordine di eventi. Senza watermark (mai visto il conto) -> `CAP_UNKNOWN_USD`.
|
|
Rende inutile l'azione manuale "al deposito alzare il cap", pre-registrata il 2026-07-02 e
|
|
rimasta ineseguita per 24 giorni."""
|
|
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
|
fixed = float(cfg.get("max_notional_per_asset_usd", 300.0))
|
|
frac = cfg.get("max_notional_per_asset_frac")
|
|
if frac is None:
|
|
return fixed
|
|
trusted = (real_equity is not None) and (not eq_fallback) and (equity is not None) and (equity > 0)
|
|
if trusted:
|
|
return float(equity) * float(frac)
|
|
wm = _read_watermark()
|
|
return min(fixed, wm * float(frac)) if wm is not None else min(fixed, CAP_UNKNOWN_USD)
|
|
|
|
|
|
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)
|
|
equity = sh["equity"]
|
|
# registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di
|
|
# fallback in OGNI ordine di eventi (vedi _cap).
|
|
equity_jump = write_equity_watermark(sh.get("real_equity"))
|
|
cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback"))
|
|
load5m = None
|
|
feed_ages: dict[str, float | None] = {}
|
|
feed_errors: dict[str, str] = {}
|
|
if live_feed:
|
|
from src.live.livefeed import feed_age_minutes, fresh_5m, last_fetch_error
|
|
|
|
def load5m(a: str):
|
|
"""Wrapper che MISURA la freschezza del feed effettivamente usato per il segnale.
|
|
`fresh_5m` ricade sul certificato in SILENZIO se il fetch pubblico fallisce: senza
|
|
questa misura la latenza d'uscita di SKH01 passerebbe da ~1h a ~1 giorno senza che
|
|
nulla lo segnali (vedi feed_age_minutes). Raccoglie anche la CAUSA del fallback:
|
|
l'eta' dice CHE il feed e' vecchio, non PERCHE' — e senza il perche' la diagnosi
|
|
non e' rifacibile a posteriori, perche' quando la si prova il guasto e' rientrato
|
|
(29/07: 6 giri stantii su 8, causa mai stabilita -> vedi livefeed._LAST_ERROR)."""
|
|
df = fresh_5m(a)
|
|
feed_ages[a] = feed_age_minutes(df)
|
|
err = last_fetch_error()
|
|
if err is not None:
|
|
feed_errors[a] = err
|
|
return df
|
|
skh_error = None
|
|
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}
|
|
skh_error = f"{type(e).__name__}: {e}" # forzato flat -> ESPORRE (sennò flat silenzioso)
|
|
|
|
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, skh_error=skh_error, pos_error=sh.get("pos_error"),
|
|
eq_fallback=sh.get("eq_fallback"),
|
|
# Salto di equity fra due letture consecutive oltre EQUITY_JUMP_ALERT: dict o None.
|
|
# Conferma un versamento (e che il sizing lo ha seguito) E segnala un movimento non
|
|
# richiesto. Il book ha vol ~0.4%/g: un salto del 10% fra due giri orari non e' trading.
|
|
equity_jump=equity_jump,
|
|
# eta' (minuti) del feed 5m REALMENTE usato per il segnale SKH: None se non misurata
|
|
# (live_feed=False) o non interpretabile. Vale il MASSIMO fra gli asset: se anche uno solo
|
|
# e' stantio, il segnale netto e' sospetto.
|
|
skh_feed_age_min=(max((v for v in feed_ages.values() if v is not None), default=None)
|
|
if feed_ages else None),
|
|
# PERCHE' la coda fresca non e' stata attaccata, per asset ({} = nessun fallback).
|
|
# Accompagna skh_feed_age_min: senza, l'allerta puo' solo TIRARE A INDOVINARE la causa
|
|
# (ed e' esattamente cio' che faceva, con una nota cablata, fino al 29/07).
|
|
skh_feed_errors=dict(feed_errors),
|
|
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
|
)
|