ops(live): staleness-gate bloccante + report Telegram giornaliero
Presa in carico operativa del conto Deribit (delega dell'utente). Nessun cambio a
strategie, pesi o sizing: il libro resta TP01 0.75 + SKH01 0.25.
1) STALENESS-GATE (protezione di capitale, era un follow-up mai cablato)
Il 2026-07-14 alle 14:00 UTC il book ha COMPRATO ETH $75 con l'ultima barra del
feed certificato ferma al 2026-07-08: feed congelato da 6 giorni (diario
2026-07-15-feed-freeze). I due gate esistenti non potevano vederlo — il conto ERA
online e la posizione ERA leggibile: proteggono dai problemi di CONTO, non da un
feed morto. Il diario raccomandava un alert; qui il gate e' BLOCCANTE, coerente
con gli altri due ("non opero a cieco"), col disaster-SL on-book come rete su
eventuali posizioni gia' aperte.
- config/live.json: max_data_age_days = 2;
- book_execute: _data_age_days() + blocco PRIMA di costruire DeribitTrader
(nessuna sessione autenticata aperta su dati morti) + alert Telegram con il
comando di sblocco; data illeggibile => trattata come stantia;
- letto con cfg.get(default): una config priva della chiave ricade sulla soglia
sicura invece di sollevare KeyError dentro il percorso con soldi veri;
- tests/test_book_staleness_gate.py: 8 casi, incluso il funzionale che riproduce
la situazione del 14/07 (online + posizione leggibile + ordine pronto) e
verifica che DeribitTrader NON venga costruito.
- tests/test_book_live.py: i 3 report finti avevano last_data="2026-07-01"
hardcoded -> ora data fresca calcolata. Quei test riguardano skh_error /
pos_error / eq_fallback, non la staleness: con la data fissa sarebbero marciti
al superamento della soglia.
2) REPORT TELEGRAM GIORNALIERO (scripts/live/telegram_daily.py, in cron_daily.sh)
Gli alert esistenti scattano solo su ordine o errore: con il libro flat — stato
normale e corretto col trend giu' — significava silenzio per settimane,
indistinguibile da un sistema morto. Il report dice ogni giorno dove sta il conto,
perche' non opera e quanto manca perche' operi (con la convenzione TP01 corretta:
media dei SEGNI, quindi servono 2 orizzonti su 3, non basta il piu' breve).
Sola lettura: un test verifica che il modulo non possa inviare ordini.
Stato al commit: equity $596.92, flat e a target, disaster-SL -30% verificato
(placed @ $1,308.7 sull'ultima posizione). TP01 0.00 su entrambi: accensione a
BTC $78.680 (+22,7%) / ETH $2.370 (+27,2%).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,23 @@ def load_config() -> dict:
|
||||
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
||||
cfg.setdefault("min_order_usd", 5.0)
|
||||
cfg.setdefault("disaster_sl_pct", 0.30)
|
||||
cfg.setdefault("max_data_age_days", 2.0)
|
||||
return cfg
|
||||
|
||||
|
||||
def _data_age_days(last_data) -> float | None:
|
||||
"""Eta' in giorni dell'ultima barra del feed certificato. None se non interpretabile
|
||||
(trattata come stantia: meglio non operare che operare su una data che non so leggere)."""
|
||||
if last_data in (None, ""):
|
||||
return None
|
||||
try:
|
||||
ts = pd.Timestamp(last_data)
|
||||
ts = ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC")
|
||||
return float((pd.Timestamp.now(tz="UTC") - ts).total_seconds() / 86400.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def log_event(rec: dict):
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG, "a") as f:
|
||||
@@ -91,6 +105,27 @@ def _run():
|
||||
"nota": "salto l'esecuzione, non opero a cieco"})
|
||||
return
|
||||
|
||||
stale_days = _data_age_days(r.get("last_data"))
|
||||
# .get col default: un chiamante che passa una config senza la chiave deve ricadere sulla
|
||||
# soglia sicura, non sollevare KeyError dentro il percorso d'esecuzione con soldi veri.
|
||||
max_age = float(cfg.get("max_data_age_days", 2.0))
|
||||
if stale_days is None or stale_days > max_age:
|
||||
# FEED STANTIO -> non eseguo. Il 2026-07-14 il book ha comprato ETH con l'ultima barra
|
||||
# ferma al 07-08 (feed congelato 6 giorni, diario 2026-07-15-feed-freeze): il conto era
|
||||
# online e la posizione leggibile, quindi i due gate esistenti NON scattavano. Il segnale
|
||||
# TP01 viene dal feed su disco: se e' vecchio, si opera alla cieca su dati morti.
|
||||
# Il disaster-SL on-book resta la rete di sicurezza su eventuali posizioni aperte.
|
||||
eta = "ignota" if stale_days is None else f"{stale_days:.0f}g"
|
||||
print(f" 🛑 FEED STANTIO (ultima barra {r.get('last_data')}, eta' {eta} > {max_age:.0f}g)"
|
||||
" -> NON eseguo su dati morti.")
|
||||
print(" Sbloccare con: uv run python scripts/analysis/rebuild_history.py --asset BTC ETH")
|
||||
if do_execute:
|
||||
notify("🛑 BOOK LIVE — FEED STANTIO, esecuzione saltata",
|
||||
{"ultima_barra": str(r.get("last_data")), "eta": eta,
|
||||
"soglia": f"{max_age:.0f}g",
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""REPORT GIORNALIERO su Telegram — stato del conto reale, cosa sta facendo il sistema e perche'.
|
||||
|
||||
PERCHE' ESISTE (2026-07-25). Gli alert Telegram esistenti (`src/live/notifier`) scattano SOLO su
|
||||
ordine eseguito o errore. Con il libro flat — che e' lo stato normale e corretto quando il trend e'
|
||||
giu' — questo significa **silenzio per settimane**, indistinguibile da un sistema morto. Questo
|
||||
report rompe il silenzio ogni giorno e dice tre cose: dove sta il conto, perche' il sistema non
|
||||
opera, e quanto manca perche' operi.
|
||||
|
||||
NON invia ordini, non tocca posizioni, non legge segreti oltre a quelli gia' usati dall'esecutore.
|
||||
E' sola lettura: se fallisce, l'esecuzione oraria del libro non ne risente in alcun modo.
|
||||
|
||||
uv run python scripts/live/telegram_daily.py # calcola e invia
|
||||
uv run python scripts/live/telegram_daily.py --dry-run # stampa e basta, non invia
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.notifier import send
|
||||
|
||||
HORIZONS = (30, 90, 180)
|
||||
GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"),
|
||||
("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")]
|
||||
|
||||
|
||||
def _fmt_usd(x: float) -> str:
|
||||
return f"${x:,.2f}"
|
||||
|
||||
|
||||
def trend_state() -> list[str]:
|
||||
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
|
||||
|
||||
NB convenzione TP01: la direzione e' la media dei SEGNI (-1/+1) sugli orizzonti, poi clippata
|
||||
a >=0 (long-flat). Con 1 orizzonte su 3 positivo la media e' -0.33 -> target 0. Serve quindi
|
||||
che DUE orizzonti siano positivi perche' il libro si accenda: non basta il piu' breve."""
|
||||
from src.data.downloader import load_data
|
||||
out = []
|
||||
for a in ("BTC", "ETH"):
|
||||
try:
|
||||
d = load_data(a, "1h")
|
||||
s = pd.Series(d["close"].astype(float).values,
|
||||
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
c = s.resample("1D").last().dropna().values
|
||||
if len(c) < max(HORIZONS) + 2:
|
||||
continue
|
||||
px = float(c[-1])
|
||||
refs = {h: float(c[-1 - h]) for h in HORIZONS}
|
||||
sg = {h: (1 if px > refs[h] else -1) for h in HORIZONS}
|
||||
m = float(np.mean(list(sg.values())))
|
||||
tgt = max(0.0, m)
|
||||
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
||||
f" → TP01 {tgt:.2f}"
|
||||
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0]
|
||||
if neg and tgt == 0.0:
|
||||
h, r = min(neg, key=lambda t: t[1])
|
||||
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
||||
out.append(line)
|
||||
except Exception as e:
|
||||
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
||||
return out
|
||||
|
||||
|
||||
def book_state() -> list[str]:
|
||||
"""Conto reale, posizioni e target netto correnti. Sola lettura."""
|
||||
try:
|
||||
from src.live.book import book_report
|
||||
r = book_report()
|
||||
eq = r.get("equity")
|
||||
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
||||
for a in r.get("assets", []):
|
||||
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
|
||||
stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
|
||||
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
|
||||
if r.get("skh_error"):
|
||||
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
||||
return lines
|
||||
except Exception as e:
|
||||
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"]
|
||||
|
||||
|
||||
def monitors() -> list[str]:
|
||||
out = []
|
||||
for name, dec, path, key in GATES:
|
||||
p = ROOT / path
|
||||
giorni = (dec - date.today()).days
|
||||
if not p.exists():
|
||||
out.append(f" {name}: in attesa della prima barra · gate {dec} (−{giorni}g)")
|
||||
continue
|
||||
try:
|
||||
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
||||
if not rows:
|
||||
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)")
|
||||
continue
|
||||
r = np.array([x[key] for x in rows], float)
|
||||
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
|
||||
tot = float(np.prod(1 + r) - 1) * 100
|
||||
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · gate {dec} (−{giorni}g)")
|
||||
except Exception:
|
||||
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)")
|
||||
return out
|
||||
|
||||
|
||||
def last_trade() -> str:
|
||||
p = ROOT / "data" / "live" / "book_executions.jsonl"
|
||||
if not p.exists():
|
||||
return " nessun ordine registrato"
|
||||
try:
|
||||
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
||||
if not rows:
|
||||
return " nessun ordine registrato"
|
||||
t = rows[-1]
|
||||
d = datetime.fromisoformat(t["ts_utc"]).replace(tzinfo=timezone.utc)
|
||||
giorni = (datetime.now(timezone.utc) - d).days
|
||||
return (f" ultimo: {t['ts_utc'][:10]} ({giorni}g fa) {t['asset']} {t['action']} "
|
||||
f"@ {t.get('price')} · totale ordini: {len(rows)}")
|
||||
except Exception:
|
||||
return " ledger ordini illeggibile"
|
||||
|
||||
|
||||
def build() -> str:
|
||||
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
||||
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
||||
L += book_state()
|
||||
L += ["", "<b>Perche' non opera</b>"]
|
||||
L += trend_state()
|
||||
L += ["", "<b>Ordini</b>", last_trade()]
|
||||
L += ["", "<b>Monitor in osservazione</b>"]
|
||||
L += monitors()
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
txt = build()
|
||||
if "--dry-run" in sys.argv[1:]:
|
||||
print(txt.replace("<b>", "").replace("</b>", ""))
|
||||
return
|
||||
print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user