feat(live): reconcile resting + orphan single-leg + circuit-breaker venue-lock + FEED_BOOK_GAP

Codice della tornata v1.1.27/28 (gia' in produzione, mai committato):
- reconcile_account: estensione ordini RESTING (FILLED_UNBOOKED/MISSING/STALE,
  caso MR02_BTC: TP fillato di notte scoperto ore dopo) + expected_resting in books
- strategy_worker: orphan_legs su REAL_CLOSE_PARTIAL anche single-leg, persistito
- execution: circuit-breaker su venue-lock admin (stop ordini dopo errori ripetuti)
- runner/hourly_report: alert FEED_BOOK_GAP + timestamp closed trades
- cerbero_client: get_open_orders (merge all + trigger_all)
Test: 12 nuovi, suite completa 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-12 20:29:02 +00:00
parent a2d581691a
commit 612f2bfced
11 changed files with 628 additions and 25 deletions
+49
View File
@@ -224,6 +224,49 @@ def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
"gap_pct": round(gap, 2), "prezzo": float(c[i])})
_GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile
def _check_feed_book_gap(client, raw1h, instruments, threshold_bps, alerted):
"""Osservabilita' (2026-06-12): il feed candele e il book dove fillano gli ordini
REALI possono divergere — caso MR02_BTC: TP resting fillato a 60481 nella notte
col feed mai sceso sotto 63285 (-443 bps, scoperto solo al close sim); i wick
TP_PHANTOM sono il caso opposto (feed stampa, book non scambia). Confronta il
close della candela in corso col MARK dello strumento d'ESECUZIONE (USDC):
oltre soglia -> alert FEED_BOOK_GAP, una notifica per episodio, recovery con
isteresi a soglia/2. Le decisioni restano sul feed (il sim e' la verita' che
guida): questo dice solo QUANDO i fill reali possono divergere dal sim."""
from src.live.telegram_notifier import notify_event
want = {a: inst for a, inst in instruments.items() if a in raw1h}
if not want:
return
try:
out = client.get_ticker_batch(list(want.values()))
marks = {t.get("instrument_name"): (t.get("mark_price") or t.get("last_price"))
for t in out.get("tickers", [])}
except Exception:
return # fail-open: solo osservabilita'
for asset, inst in want.items():
mark = marks.get(inst)
feed = float(raw1h[asset]["close"].iloc[-1])
if not mark or not feed:
continue
gap_bps = abs(feed / float(mark) - 1) * 10_000
if gap_bps >= threshold_bps and asset not in alerted:
alerted.add(asset)
print(f"[runner] FEED_BOOK_GAP {asset}: feed {feed} vs mark {mark} "
f"({gap_bps:.0f} bps)")
notify_event("FEED_BOOK_GAP", {
"asset": asset, "feed_close": feed, "mark_book": float(mark),
"gap_bps": round(gap_bps, 1),
"nota": "feed candele != book d'esecuzione: i fill reali possono "
"divergere dal sim (TP fantasma / fill non visti dal feed)"})
elif gap_bps < threshold_bps / 2 and asset in alerted:
alerted.discard(asset)
notify_event("FEED_BOOK_GAP", {"asset": asset, "status": "RIENTRATO",
"gap_bps": round(gap_bps, 1)})
def run(config_path: str = "portfolios.yml"):
"""Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample;
ribilancio a cambio giornata UTC."""
@@ -380,6 +423,9 @@ def run(config_path: str = "portfolios.yml"):
inst_map = dict(INSTRUMENT_MAP)
last_day = ""
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
# guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off)
gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT))
gap_alerted: set[str] = set()
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
@@ -432,6 +478,9 @@ def run(config_path: str = "portfolios.yml"):
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
_check_stale_feed(asset, raw1h[asset], stale_alerted)
if exec_enabled and gap_bps > 0:
_check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted)
# fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items():