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>
This commit is contained in:
@@ -56,6 +56,16 @@ orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni
|
|||||||
in 2 minuti) — accettabile su testnet, NON per capitale vero.
|
in 2 minuti) — accettabile su testnet, NON per capitale vero.
|
||||||
Raccomandazione: C ora, A prima di passare a capitale reale.
|
Raccomandazione: C ora, A prima di passare a capitale reale.
|
||||||
|
|
||||||
|
**Primo passo verso A — FATTO (sera stessa): reconciler read-only.**
|
||||||
|
`scripts/analysis/reconcile_account.py`: per ogni strumento USDC confronta
|
||||||
|
atteso (Σ quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs
|
||||||
|
registrati = drift SPIEGATO) vs conto reale (`get_positions`, size/mark → coin),
|
||||||
|
tolleranza 1.5×step, anti-race (ricontrollo a 10s prima di segnalare). In crontab
|
||||||
|
host ORARIO (:40) con alert Telegram `ACCOUNT_DRIFT`. Al primo run ha beccato un
|
||||||
|
vero positivo: BTC libro short 0.0028 vs conto flat (il TP di MR02_BTC fillato
|
||||||
|
dallo spike reale delle 12:22Z, che il worker riconcilierà alla chiusura sim) —
|
||||||
|
esattamente la classe di divergenza che prima restava invisibile per ore.
|
||||||
|
|
||||||
## Altri esiti della tornata
|
## Altri esiti della tornata
|
||||||
|
|
||||||
- **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate
|
- **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""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)
|
||||||
Reference in New Issue
Block a user