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:
@@ -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)
|
||||
Reference in New Issue
Block a user