Files
PythagorasGoal/scripts/analysis/reconcile_account.py
T
Adriano Dal Pastro 22b77668f1 feat(ops): reconciler read-only conto vs libri worker (primo passo verso il position-manager)
scripts/analysis/reconcile_account.py: per strumento USDC confronta l'atteso
(somma quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs
registrati = drift spiegato) col conto reale (get_positions, size/mark -> coin).
Tolleranza 1.5x step contratto, anti-race (ricontrollo a 10s), alert Telegram
ACCOUNT_DRIFT con --telegram. SOLO lettura, gira dall'host (cron orario :40,
nessun deploy). Al primo run: vero positivo su BTC (libro MR02 short 0.0028 vs
conto flat — TP fillato dallo spike reale, si riconcilia alla chiusura sim).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:43:17 +00:00

137 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
def _inst(asset: str) -> str:
return f"{asset}_USDC-PERPETUAL"
def expected_books() -> tuple[dict[str, float], dict[str, float]]:
"""(atteso per strumento dai libri, quota orfana registrata per strumento).
Amount firmato in base-coin: buy=+, sell=-."""
books: dict[str, float] = {}
orphans: dict[str, float] = {}
for sp in sorted(PAPER.glob("*/status.json")):
st = json.loads(sp.read_text())
wid = sp.parent.name
parts = wid.split("__")
if st.get("real_in_position"):
if "real_amount_a" in st and st.get("real_amount_a"):
# pairs: asset da worker_id (…__ETH_BTC__1h)
a, b = parts[1].split("_")
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
(b, st["real_side_b"], st["real_amount_b"])):
sgn = 1 if side == "buy" else -1
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
elif st.get("real_amount"):
# single-leg: fade/DIP01/SH01
sgn = 1 if st.get("real_side") == "buy" else -1
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
for o in st.get("orphan_legs", []):
# gamba che il libro ha chiuso ma il conto ha ancora (entry_side = verso
# in cui la posizione e' rimasta aperta sul conto)
sgn = 1 if o.get("entry_side") == "buy" else -1
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
return books, orphans
def account_positions(client: CerberoClient) -> dict[str, float]:
"""Posizioni reali per strumento, amount firmato in base-coin."""
out: dict[str, float] = {}
for p in client.get_positions(currency="USDC") or []:
inst = p.get("instrument")
size = float(p.get("size") or 0)
mark = float(p.get("mark_price") or 0)
if not inst or not size or not mark:
continue
amt = size / mark
out[inst] = amt if p.get("direction") == "long" else -amt
return out
def compute_drift() -> list[dict]:
client = CerberoClient()
books, orphans = expected_books()
acct = account_positions(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 main():
rows = compute_drift()
bad = [r for r in rows if not r["ok"]]
if bad:
# anti-race: un worker poteva essere a meta' open/close -> ricontrolla
print(f"drift su {len(bad)} strumenti: ricontrollo fra {RECHECK_SLEEP}s (anti-race)...")
time.sleep(RECHECK_SLEEP)
rows = compute_drift()
bad = [r for r in rows if not r["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("\nESITO:", "OK — conto allineato ai libri" if not bad
else f"⚠️ DRIFT PERSISTENTE su {len(bad)} strumenti")
if bad and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
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")
return bool(bad)
if __name__ == "__main__":
sys.exit(1 if main() else 0)