Files
PythagorasGoal/src/live/book.py
T
Adriano 829ebc7f98 book: min_order_usd da config nel report (#35 A7)
book_report costruiva l'ordine con min_usd=5.0 fisso invece di leggere
min_order_usd da config/live.json, come fa gia' scripts/live/book_execute.py
(default 5.0 se assente). Oggi config = 5, nessun cambio di comportamento.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 15:17:42 +00:00

389 lines
22 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
# ---- CHIAVE DI SCALA (GATE SCALA-01) — spec: docs/research/SPEC-scale-key.md ------------------
# ⚠️ COSTANTI DI CODICE, NON DI CONFIG, e il motivo e' il meccanismo del cricchetto (SPEC §4.1b):
# un tetto che vive nello stesso file della chiave sarebbe "un lucchetto con la chiave attaccata".
# Portare il gradino a 1,50 richiede una modifica di CODICE -> una review -> un gate.
# Il tetto e' basso APPOSTA: non e' un'opinione sulla leva massima sopportabile (che da §4.5
# sarebbe 1,67x), e' il dispositivo che rende impossibile alzarla "solo un po'".
LEVA_LORDA_MAX = 1.25
# La scaletta esiste perche' una scala A GRADINI rende INESPRIMIBILE "solo un po'": 1,05 non e'
# un valore prudente, e' un valore fuori scaletta, e viene RIFIUTATO.
SCALA_LADDER = (1.00, 1.25)
# Invariante del disaster-SL (SPEC §4.5, la guardia che morde per PRIMA): il costo in equity di un
# episodio di disaster-SL e' `n_asset * frac * scala * disaster_sl_pct` e non deve superare mezza
# equity. Con frac 0,50 e sl 30% da' scala <= 1,67x, e scatta se qualcuno allarga il disaster-SL.
DISASTER_SL_BUDGET = 0.50
class ScalaNonAutorizzata(RuntimeError):
"""Config che dichiara una scala fuori scaletta o fuori tetto.
⚠️ E' un'ECCEZIONE e non un clamp, ed e' una scelta (SPEC §4.1c): tagliare silenziosamente al
tetto farebbe girare una config che DICHIARA un numero e un libro che ne ESEGUE un altro —
la stessa classe di difetto che questo progetto ha gia' pagato tre volte. Chi la incontra
ferma l'esecuzione e allerta; non "rende robusto" il lettore con un min().
"""
def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
cfg: dict | None = None) -> float:
"""Scala del libro (`book_scale_k` in config). 1.0 = il libro di oggi, bit-exact.
DUE COMPORTAMENTI, entrambi voluti:
(1) VALIDA SEMPRE, anche sul percorso degradato. Una config fuori scaletta o fuori tetto e'
un errore di configurazione, non una condizione di mercato: dev'essere scoperta al primo
giro, non nascosta dal fatto che oggi l'equity non era leggibile.
(2) POI applica la regola G3/G4: **la scala vive SOLO sul percorso fidato**. Se l'equity reale
non e' leggibile la scala e' 1,00 — *una scala e' una decisione di rischio presa conoscendo
il conto; quando non si sa quanto vale il conto, non si prende*. Cosi' "il fallback non e'
piu' permissivo" e' vero PER COSTRUZIONE, non per aritmetica.
Il tetto e' sul PRODOTTO `n_asset * frac * scala`, mai sulla sola chiave: con un tetto sulla
sola chiave, alzare `frac` da 0,50 a 0,625 con scala 1,25 darebbe 1,562x passando il controllo.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
raw = cfg.get("book_scale_k")
if raw is None:
return 1.0 # chiave assente = il libro di oggi
s = float(raw)
if not any(abs(s - x) < 1e-9 for x in SCALA_LADDER):
raise ScalaNonAutorizzata(
f"book_scale_k={s!r} non e' un gradino di SCALA_LADDER {SCALA_LADDER}: "
f"una scala a gradini rende inesprimibile 'solo un po''. Config: {CONFIG}")
frac = cfg.get("max_notional_per_asset_frac")
if frac is not None:
leva = len(ASSETS) * float(frac) * s
if leva > LEVA_LORDA_MAX + 1e-12:
raise ScalaNonAutorizzata(
f"leva lorda {leva:.4f}x = n_asset {len(ASSETS)} x frac {frac} x scala {s} "
f"supera LEVA_LORDA_MAX {LEVA_LORDA_MAX}x (tetto sul PRODOTTO, src/live/book.py). "
f"Alzarlo e' una modifica di CODICE, non di config.")
sl = float(cfg.get("disaster_sl_pct", 0.0))
costo = leva * sl
if costo > DISASTER_SL_BUDGET + 1e-12:
raise ScalaNonAutorizzata(
f"un episodio di disaster-SL costerebbe {100*costo:.1f}% dell'equity "
f"(n_asset x frac {frac} x scala {s} x sl {sl}) > {100*DISASTER_SL_BUDGET:.0f}%: "
f"e' la guardia che morde per prima (SPEC-scale-key §4.5).")
if real_equity is None or eq_fallback:
return 1.0 # G3/G4: niente scala fuori dal percorso fidato
return s
def leva_tetto(cfg: dict | None = None) -> tuple[float, str]:
"""Leva lorda massima che il libro PUO' prendere. UNA formula, non tre (#28): prima era
ridichiarata in `usde.frazione_cuscino` (N_ASSET fisso, senza scala) e nella regola `leva` del
giornale (senza scala) -> a scala 1,25 il cuscino USDC risultava 0,30 dell'equity invece di
0,375, e la regola `leva` mostrava un tetto diverso da quello che il libro rispetta davvero.
leva = len(ASSETS) x min(WEIGHT, frac) x scala, al piu' LEVA_LORDA_MAX. -> (leva, come).
- frac = cfg["max_notional_per_asset_frac"] se presente, altrimenti WEIGHT. Il min(WEIGHT, frac)
viene dal fatto che `book_net_target` non supera mai weight*equity*(W_TP01+W_SKH) =
weight*equity (i due pesi sommano a 1): |raw| <= weight*equity, quindi |net_target| <=
scala*min(cap, weight*equity) -- un frac oltre WEIGHT alza il cap nominale ma non la leva
REALE che il libro puo' esprimere.
- scala = la scala DICHIARATA (`_scala(real_equity=1.0, cfg=cfg)`, gia' validata): un tetto
descrive quanto la config AUTORIZZA, non quanto un giro con equity illeggibile ha preso.
- Su `ScalaNonAutorizzata` (config fuori scaletta o fuori tetto) NON si solleva: un
sorvegliante (`cuscino_watch`) non deve morire proprio quando la config e' sbagliata
(verifica fable B5) -> si ritorna il tetto di CODICE, dichiarando l'anomalia in `come`.
"""
if cfg is None:
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
try:
scala = _scala(real_equity=1.0, cfg=cfg)
except ScalaNonAutorizzata as e:
breve = str(e).splitlines()[0]
breve = breve if len(breve) <= 80 else breve[:77] + "..."
return LEVA_LORDA_MAX, (f"config fuori scaletta/tetto ({breve}): "
f"tetto di codice LEVA_LORDA_MAX")
raw_frac = cfg.get("max_notional_per_asset_frac")
frac = float(raw_frac) if raw_frac is not None else WEIGHT
frac_eff = min(WEIGHT, frac)
leva = len(ASSETS) * frac_eff * scala
base = f"{len(ASSETS)} asset x frac {frac_eff} x scala {scala} = {leva:.2f}x"
if leva > LEVA_LORDA_MAX + 1e-12:
return LEVA_LORDA_MAX, f"{base}, troncato al tetto di codice {LEVA_LORDA_MAX}x"
return leva, base
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, scala: float = 1.0) -> 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.
⚠️ LA SCALA SI APPLICA **DOPO** IL CLAMP, e non e' una scelta di comodo (SPEC §3.2).
Applicandola PRIMA, il clamp la mangerebbe **proprio nei giorni di massima convinzione** (a
`tp=1, sg=+1` il grezzo vale esattamente `cap`, quindi k_eff tornerebbe a 1,00 a ogni k): il
risultato non sarebbe "il libro a leva k" ma un libro che alza i giorni piccoli e lascia fermi
i grandi — un cambio di FORMA travestito da cambio di taglia, che la curva g(k) con cui il
gradino viene autorizzato **non descrive**. DOPO il clamp, `k_eff == k` in ogni giorno.
Il prezzo, dichiarato: il cap smette di essere il tetto assoluto del nozionale e diventa il
tetto del libro UNITARIO; la guardia sulla leva lorda e' ricostruita in `_scala` (SPEC §4).
Con `scala=1.0` (chiave assente) questa funzione e' bit-exact quella di prima."""
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
return scala * 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)."""
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
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"))
# La scala VALIDA sempre e si applica solo sul percorso fidato: se la config e' fuori scaletta
# o fuori tetto, `_scala` SOLLEVA e book_execute si ferma senza inviare (SPEC §4.1c).
scala = _scala(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, scala=scala)
cur = float(a_rec["position_usd"])
mark = a_rec["mark"]
order = build_book_order(inst, net, cur, mark, min_usd=float(cfg.get("min_order_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),
# La scala EFFETTIVA del giro (1.0 = libro di oggi). Esposta perche' un parametro che muove
# il nozionale e non compare nel report e' la forma esatta di un difetto gia' pagato.
scala=scala, leva_lorda_max=LEVA_LORDA_MAX,
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),
)