chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
+310
View File
@@ -0,0 +1,310 @@
"""Reconciler READ-ONLY conto Deribit vs libri dei worker (primo passo verso il
position-manager, audit 2026-06-11: il conto era short 0.027 ETH oltre i libri e
nessuno se n'era accorto per ore).
Confronta, per ogni strumento USDC:
atteso = Σ quote reali dei worker (status.json persistiti: fade/DIP/SH single-leg
+ pairs a 2 gambe) + Σ gambe ORFANE registrate (orphan_legs: posizioni
che il conto ha ancora ma i libri hanno chiuso — drift SPIEGATO)
reale = get_positions(currency=USDC) (size USD / mark = amount in coin)
Drift oltre tolleranza (1.5×step del contratto) -> tabella + alert Telegram
`ACCOUNT_DRIFT` (con --telegram). Anti-race: se al primo passaggio c'e' drift,
rilegge libri+conto dopo qualche secondo e segnala solo se persiste (un worker
poteva essere a meta' di un open/close).
Nessun ordine, nessuna modifica di stato: SOLO lettura.
uv run python scripts/analysis/reconcile_account.py # stampa
uv run python scripts/analysis/reconcile_account.py --telegram # + alert se drift
"""
from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.execution import contract_spec
from src.live.books import real_books, account_net, expected_resting, PAPER
# fonte UNICA dei libri (usata
# anche dal guard del netting)
RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
STALE_REAL_MIN = 15 # un worker vivo aggiorna status.json ~ogni poll (60s);
# real_in_position con mtime oltre questo = NON gestito
# --- Watermark FONDI (2026-06-17): rileva aumenti di capitale (depositi/top-up) e
# cali anomali (prelievi) confrontando il balance USDC col precedente osservato.
# Serve a sapere quando un versamento e' arrivato (per scalare total_capital). ---
FUNDS_STATE = PROJECT_ROOT / "data" / "funds_watch.json" # data/ e' scrivibile dal
# cron host (adriano); data/portfolios e' root (container)
FUNDS_DELTA_MIN = 25.0 # USDC: sotto questo Δbalance e' rumore di trading (PnL/fee orarie)
FUNDS_DELTA_PCT = 0.05 # ...o 5% del balance precedente (robusto al crescere del conto)
def compute_drift(client: CerberoClient | None = None) -> list[dict]:
client = client or CerberoClient()
books, orphans = real_books()
acct = account_net(client)
rows = []
for inst in sorted(set(books) | set(orphans) | set(acct)):
exp = books.get(inst, 0.0) + orphans.get(inst, 0.0)
real = acct.get(inst, 0.0)
step = contract_spec(inst).get("step", 0.001)
tol = TOL_STEPS * step
rows.append(dict(inst=inst, books=books.get(inst, 0.0),
orphans=orphans.get(inst, 0.0), exp=exp, real=real,
drift=real - exp, tol=tol,
ok=abs(real - exp) <= tol))
return rows
def _book_orders(client: CerberoClient) -> dict[str, dict]:
"""Ordini aperti sul conto, merge type='all' + 'trigger_all' per order_id
(Deribit puo' omettere i trigger untriggered da 'all')."""
orders: dict[str, dict] = {}
for typ in ("all", "trigger_all"):
for o in client.get_open_orders(currency="USDC", type=typ) or []:
oid = str(o.get("order_id") or "")
if oid:
orders[oid] = o
return orders
def _resting_filled(client: CerberoClient, instrument: str, order_id: str) -> float:
"""Amount fillato di un ordine resting dal trade history (fonte autorevole)."""
try:
return sum(float(t.get("amount", 0) or 0)
for t in client.get_trade_history(limit=100,
instrument_name=instrument)
if str(t.get("order_id")) == str(order_id))
except Exception:
return 0.0
def compute_resting_drift(client: CerberoClient | None = None) -> list[dict]:
"""Reconcile degli ordini RESTING (estensione 2026-06-12, dopo il caso MR02_BTC:
TP resting fillato sul book di notte + disaster-SL sparito, scoperti solo al
close sim ore dopo). Tre classi di anomalia:
FILLED_UNBOOKED l'ordine atteso non e' in book e ha fill nel trade history
mentre il worker si crede ancora in posizione (il caso MR02)
MISSING l'ordine atteso non e' in book e non ha fill (cancellato da
altri/exchange; per il DSL triggered il fill ha un order_id
NUOVO -> qui appare MISSING e il drift posizioni completa)
STALE ordine in book con label di un nostro worker ma NON atteso
dai libri (worker flat/morto: fillerebbe a sorpresa)
"""
client = client or CerberoClient()
expected = expected_resting()
book = _book_orders(client)
rows: list[dict] = []
for e in expected:
if e["order_id"] in book:
rows.append({**e, "status": "OK"})
continue
filled = _resting_filled(client, e["instrument"], e["order_id"])
rows.append({**e, "status": "FILLED_UNBOOKED" if filled > 0 else "MISSING",
"filled": filled})
exp_ids = {e["order_id"] for e in expected}
workers = {p.name for p in PAPER.glob("*") if p.is_dir()}
for oid, o in book.items():
if oid not in exp_ids and str(o.get("label") or "") in workers:
rows.append(dict(worker=o.get("label"), instrument=o.get("instrument"),
order_id=oid, kind=o.get("order_type"),
status="STALE"))
return rows
def compute_stale_real_positions(max_age_min: int = STALE_REAL_MIN) -> list[dict]:
"""Worker che dichiarano una posizione REALE ma il cui status.json e' fermo da
oltre max_age_min minuti = posizione NON gestita da nessun runner (caso
MR02_BTC 1h del 2026-06-12: ritirato dallo swap a 15m mentre short reale, con
TP perso nel netting -> short nudo su disaster-SL). Il drift di compute_drift
NON lo vede: lo status fermo conta ancora come 'libro' -> conto == libri.
Discriminante robusto e venue-agnostico: la STALENESS (un worker vivo scrive
a ogni poll), cattura ritirati-da-swap, crashati, worker rimossi dal config."""
now = time.time()
out: list[dict] = []
for sp in sorted(PAPER.glob("*/status.json")):
try:
st = json.loads(sp.read_text())
except Exception:
continue
if not st.get("real_in_position"):
continue
age_min = (now - sp.stat().st_mtime) / 60.0
if age_min > max_age_min:
out.append(dict(worker=sp.parent.name, age_min=round(age_min, 1),
real_amount=st.get("real_amount") or st.get("real_amount_a"),
real_side=st.get("real_side") or st.get("real_side_a", "")))
return out
def compute_funds_change(client: CerberoClient | None = None,
currency: str = "USDC") -> dict:
"""Watermark dei FONDI: confronta il `balance` USDC corrente con l'ultimo
osservato (persistito in FUNDS_STATE) e segnala un AUMENTO (probabile deposito/
top-up di capitale) o un CALO anomalo (probabile prelievo). Read-only; aggiorna
SEMPRE il watermark (-> l'alert scatta UNA volta sul gradino, non si ripete).
Si traccia `balance` (cassa realizzata: cambia su deposito/prelievo/PnL chiuso/fee),
NON `equity` (che ondeggia con l'unrealized -> falsi positivi). Soglia robusta =
max(FUNDS_DELTA_MIN, FUNDS_DELTA_PCT × balance_prec): il PnL realizzato di trading
in un'ora resta sotto, un versamento la supera nettamente. Primo run = solo seed."""
client = client or CerberoClient()
s = client.get_account_summary(currency)
cur_bal = float(s.get("balance", s.get("equity", 0.0)) or 0.0)
cur_eq = float(s.get("equity", cur_bal) or 0.0)
iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
out = dict(currency=currency, balance=cur_bal, equity=cur_eq, ts=iso,
prev_balance=None, delta=0.0, thr=0.0, kind="seed")
prev = None
if FUNDS_STATE.exists():
try:
prev = json.loads(FUNDS_STATE.read_text())
except Exception:
prev = None
if prev is not None and "balance" in prev:
pb = float(prev["balance"])
delta = cur_bal - pb
thr = max(FUNDS_DELTA_MIN, FUNDS_DELTA_PCT * abs(pb))
out.update(prev_balance=pb, prev_ts=prev.get("ts"), delta=delta, thr=thr,
kind="increase" if delta >= thr else
"decrease" if delta <= -thr else "flat")
try: # persisti il nuovo watermark
FUNDS_STATE.parent.mkdir(parents=True, exist_ok=True)
FUNDS_STATE.write_text(json.dumps(
{"ts": iso, "balance": cur_bal, "equity": cur_eq, "currency": currency}))
except Exception as e:
out["persist_error"] = str(e)
return out
def main():
client = CerberoClient()
rows = compute_drift(client)
resting = compute_resting_drift(client)
stale = compute_stale_real_positions()
try:
funds = compute_funds_change(client)
except Exception as e:
funds = {"kind": "error", "error": str(e)}
bad = [r for r in rows if not r["ok"]]
bad_rest = [r for r in resting if r["status"] != "OK"]
if bad or bad_rest:
# anti-race: un worker poteva essere a meta' open/close -> ricontrolla
print(f"anomalie (pos={len(bad)} resting={len(bad_rest)}): "
f"ricontrollo fra {RECHECK_SLEEP}s (anti-race)...")
time.sleep(RECHECK_SLEEP)
rows = compute_drift(client)
resting = compute_resting_drift(client)
bad = [r for r in rows if not r["ok"]]
bad_rest = [r for r in resting if r["status"] != "OK"]
print(f"{'strumento':<22}{'libri':>10}{'orfani':>9}{'atteso':>10}{'conto':>10}{'drift':>10} esito")
for r in rows:
print(f"{r['inst']:<22}{r['books']:>10.4f}{r['orphans']:>9.4f}{r['exp']:>10.4f}"
f"{r['real']:>10.4f}{r['drift']:>+10.4f} {'OK' if r['ok'] else '⚠️ DRIFT'}")
if not rows:
print("(nessuna posizione attesa ne' reale)")
print(f"\n{'resting':<45}{'kind':>6} {'order_id':<22} stato")
for r in resting:
print(f"{r['worker']:<45}{r['kind']:>6} {r['order_id']:<22} "
f"{'OK' if r['status'] == 'OK' else '⚠️ ' + r['status']}")
if not resting:
print("(nessun ordine resting atteso ne' in book)")
print(f"\n{'worker stantio (pos. reale non gestita)':<45}{'amount':>10}{'eta_min':>9}")
for r in stale:
print(f"{r['worker']:<45}{(r['real_amount'] or 0):>10}{r['age_min']:>9.0f} ⚠️ STALE_REAL")
if not stale:
print("(nessuna posizione reale stantia)")
print(f"\n{'FONDI conto (' + funds.get('currency', 'USDC') + ')':<30}{'prec':>12}{'attuale':>12}{'Δ':>12}")
if funds["kind"] == "error":
print(f" ⚠️ lettura fondi fallita: {funds.get('error', '')[:120]}")
elif funds["kind"] == "seed":
print(f" baseline registrata: balance={funds['balance']:.2f} (primo run, nessun confronto)")
else:
tag = {"increase": "⬆️ AUMENTO (deposito?)", "decrease": "⬇️ CALO (prelievo?)",
"flat": "= invariato"}[funds["kind"]]
print(f" {'balance':<27}{funds['prev_balance']:>12.2f}{funds['balance']:>12.2f}"
f"{funds['delta']:>+12.2f} {tag} (soglia ±{funds['thr']:.2f})")
print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest or stale)
else f"⚠️ ANOMALIE (drift_pos={len(bad)} resting={len(bad_rest)} stale={len(stale)})")
if stale and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
notify_event("STALE_REAL_POSITION", {
"worker": [{k: r.get(k) for k in ("worker", "real_amount", "real_side",
"age_min")} for r in stale],
"note": ("posizione REALE con status.json fermo da >%d min = worker non "
"gestito (ritirato da uno swap di timeframe / crashato): la "
"posizione e' nuda sul conto, flattare a mano. Caso MR02_BTC 1h "
"del 2026-06-12." % STALE_REAL_MIN)})
print("[telegram] alert STALE_REAL_POSITION inviato")
if (bad or bad_rest) and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
if bad:
notify_event("ACCOUNT_DRIFT", {
"strumenti": {r["inst"]: {"atteso": round(r["exp"], 5),
"conto": round(r["real"], 5),
"drift": round(r["drift"], 5)} for r in bad},
"note": ("conto != libri worker oltre tolleranza (drift NON spiegato dagli "
"orfani registrati): verificare close cappati/gambe respinte — "
"vedi docs/diary/2026-06-11-system-audit.md")})
print("[telegram] alert ACCOUNT_DRIFT inviato")
if bad_rest:
notify_event("RESTING_DRIFT", {
"ordini": [{k: r.get(k) for k in ("worker", "kind", "order_id", "status",
"filled")} for r in bad_rest],
"note": ("FILLED_UNBOOKED = resting fillato col worker ancora in posizione "
"(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; "
"STALE = in book senza libro corrispondente")})
print("[telegram] alert RESTING_DRIFT inviato")
if funds.get("kind") in ("increase", "decrease") and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
ev = "FUNDS_INCREASE" if funds["kind"] == "increase" else "FUNDS_DECREASE"
notify_event(ev, {
"valuta": funds["currency"],
"balance_prec": round(funds["prev_balance"], 2),
"balance_attuale": round(funds["balance"], 2),
"delta": round(funds["delta"], 2),
"equity": round(funds["equity"], 2),
"note": ("AUMENTO fondi (probabile DEPOSITO/top-up): per usarli, scala "
"total_capital in portfolios.yml e riavvia il runner "
"(docker compose up -d)" if funds["kind"] == "increase"
else "CALO fondi non spiegato dal trading (prelievo? verifica il conto)")})
print(f"[telegram] alert {ev} inviato")
return bool(bad or bad_rest or stale)
if __name__ == "__main__":
# Il run delle 11:40 del 2026-06-12 e' morto in silenzio su un 502 (Deribit/gateway
# giu') -> il guardiano che non suona e' indistinguibile dal "tutto ok". Su errore:
# alert RECONCILE_FAIL (con --telegram) + exit 2.
try:
sys.exit(1 if main() else 0)
except Exception as exc:
print(f"RECONCILE_FAIL: {exc}")
if "--telegram" in sys.argv:
try:
from src.live.telegram_notifier import notify_event
notify_event("RECONCILE_FAIL", {
"errore": str(exc)[:200],
"note": "reconciler NON eseguito: conto non verificato in quest'ora"})
except Exception:
pass
sys.exit(2)