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