fix(live): 4 hardening da code-review — REAL_DIVERGENCE, outage su feed vuoto, bars.py condiviso, DSL cancel retry

Review multi-agente 7-angoli su 8c4e1cd..HEAD + check trades live:

1. Alert Telegram REAL_DIVERGENCE quando |slippage sim/reale| >= 100bps a
   open/close. Causa scatenante: spike print testnet 10:37 (candela 10:00
   H=65618, O/C ~62400) -> 3 fade BTC short su close fantasma 65266.5, reale
   fillato a 62395 (-440bps), sim +2.26 mai esistiti — passato in silenzio.
2. FEED_OUTAGE anche su feed degradato SENZA eccezione (HTTP 200 + candles
   vuote: i worker saltavano il tick in silenzio, streak a 0). Helper unico
   _outage_tick; chiavi payload uniformate (minuti su start e RIPRESO).
3. src/live/bars.py: detection forming-bar unificata (bar_ms_of /
   last_bar_is_forming / last_settled_idx) — era copiata in 4 punti
   (strategy_worker, basket, pairs, _check_stale_feed hardcoded 1h).
   E' l'invariante di sicurezza EXIT-16: ora una sola implementazione testata.
4. DSL cancel hardening in _real_close: retry su errore transitorio + alert
   REAL_DSL_CANCEL_FAIL se lo stop resta forse orfano sul book (prima l'id
   veniva dimenticato in silenzio); order_not_found = probabile trigger in
   outage -> solo log (il close a valle esce gia' verified=False).

Refutato il finding top dei finder ("stop_market senza trigger"): cerbero-mcp
traduce price->trigger_price+mark, e in produzione 2 DSL armati + 1 ciclo
completo pulito (MR07_BTC).

Test: 83/83 (9 nuovi: bars helper + DSL/divergence con executor finto).
Smoke testnet 4 scenari verdi, conto flat, zero falsi allarmi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-07 14:59:58 +00:00
parent 7bf8b197e0
commit f5173fba06
9 changed files with 294 additions and 48 deletions
+44
View File
@@ -0,0 +1,44 @@
"""Helper condivisi sulle barre OHLCV live (lezione EXIT-16, 2026-06-05).
La riga -1 di un df di candele e' la candela IN FORMAZIONE finche' non e'
trascorsa la sua durata: valutare segnali/exit-confirm su quella riga
reintroduce la wick-sensitivity che EXIT-16 elimina (audit live: 2 stop su 3
del crash ETH erano wick-stop sulla barra in corso). Questa e' l'UNICA
implementazione della detection — prima era copiata in 4 punti
(strategy_worker, basket_trend_worker, pairs_worker, runner._check_stale_feed)
con una variante hardcoded a 1h: una correzione applicata a una copia e
dimenticata nelle altre reintrodurrebbe il bug in silenzio.
"""
from __future__ import annotations
import time
import numpy as np
def bar_ms_of(ts_ms, window: int = 50) -> int:
"""Durata stimata della barra: mediana dei diff degli ultimi `window`
timestamp (ms). 0 se la serie e' troppo corta per stimarla."""
ts = np.asarray(ts_ms, dtype="int64")
if len(ts) < 2:
return 0
return int(np.median(np.diff(ts[-window:])))
def last_bar_is_forming(ts_ms, now_ms: int | None = None) -> bool:
"""True se la riga -1 e' la candela IN CORSO (now < ts[-1] + durata barra)."""
ts = np.asarray(ts_ms, dtype="int64")
if len(ts) == 0:
return False
bar = bar_ms_of(ts)
if not bar:
return False
if now_ms is None:
now_ms = int(time.time() * 1000)
return now_ms < int(ts[-1]) + bar
def last_settled_idx(ts_ms, now_ms: int | None = None) -> int:
"""Indice dell'ultima barra COMPLETATA: -1 se la riga -1 e' chiusa,
-2 se e' in formazione."""
return -2 if last_bar_is_forming(ts_ms, now_ms) else -1
+4 -5
View File
@@ -60,15 +60,14 @@ class BasketTrendWorker:
df = data.get(a)
if df is None or len(df) < 111:
continue
# Scarta la barra 4h IN FORMAZIONE (la riga -1 e' la candela in corso
# finche' non e' trascorsa la sua durata): crossover EMA e booking del
# return valutati SOLO su barre COMPLETE, come il reference
# Scarta la barra 4h IN FORMAZIONE: crossover EMA e booking del return
# valutati SOLO su barre COMPLETE, come il reference
# honest_improve2._tr_basket_daily (lezione EXIT-16; evidenza live: flip
# SOL 0->1->0 in 59min nella stessa finestra 4h, -9.3% di glitch).
from src.live.bars import last_bar_is_forming
ts_arr = df["timestamp"].values.astype("int64")
bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0
c = df["close"].values
if bar_ms and now_ms < int(ts_arr[-1]) + bar_ms:
if last_bar_is_forming(ts_arr, now_ms):
c, ts_arr = c[:-1], ts_arr[:-1]
if len(c) < 110:
continue
+4 -7
View File
@@ -16,7 +16,6 @@ Stato persistente (resume al restart) e log come StrategyWorker.
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -179,13 +178,11 @@ class PairsWorker:
m = df_a[["timestamp", "close"]].rename(columns={"close": "ca"}).merge(
df_b[["timestamp", "close"]].rename(columns={"close": "cb"}), on="timestamp", how="inner"
).sort_values("timestamp").reset_index(drop=True)
# Scarta la barra IN FORMAZIONE (la riga -1 e' la candela in corso finche'
# non e' trascorsa la sua durata): entry ED exit valutati SOLO sul close di
# Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di
# barra COMPLETA, come il backtest (pairs_research: close settled) —
# lezione EXIT-16 (strategy_worker.tick).
ts_arr = m["timestamp"].values.astype("int64")
bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0
if bar_ms and int(time.time() * 1000) < int(ts_arr[-1]) + bar_ms:
# lezione EXIT-16. Detection condivisa: src.live.bars.
from src.live.bars import last_bar_is_forming
if last_bar_is_forming(m["timestamp"].values):
m = m.iloc[:-1]
if len(m) < self.n + 2:
return
+38 -10
View File
@@ -16,6 +16,11 @@ from src.live.execution import ExecutionClient
FEE_RT = 0.002
# Alert REAL_DIVERGENCE: |slippage sim/reale| oltre questa soglia a open/close ->
# Telegram. Cattura gli spike print testnet (2026-06-07: sim short BTC a 65266.5 con
# mark reale 62395, -440bps, passato in silenzio) e i feed stantii.
DIVERGENCE_BPS = 100.0
class StrategyWorker:
"""Gestisce paper trading per una singola strategia/asset/tf."""
@@ -248,6 +253,10 @@ class StrategyWorker:
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
# il sim sta per bookare PnL che il reale non vede
self._notify("REAL_DIVERGENCE", {"fase": "open", **data})
self._place_real_tp()
self._place_disaster_sl()
else:
@@ -326,12 +335,28 @@ class StrategyWorker:
# il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False)
# NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
# order_not_found); 'error' = ordine non piu' in book (probabile trigger).
# order_not_found). 'order_not_found' = ordine non piu' in book (probabile
# trigger durante outage: il market a valle filla 0 -> verified=False).
# Altri errori (rete/transitorio): RETRY, poi alert Telegram — dimenticare
# un id con lo stop ancora in book lascia un ORFANO che puo' colpire la
# PROSSIMA posizione del worker.
if self.real_dsl_order_id:
dres = self.executor.cancel_order(self.real_dsl_order_id)
if dres.get("state") not in ("cancelled", "untriggered"):
self._log("REAL_DSL_CANCEL_FAIL", {"order_id": self.real_dsl_order_id,
"res": dres})
def _dsl_cancel():
d = self.executor.cancel_order(self.real_dsl_order_id)
return (d, d.get("state") in ("cancelled", "untriggered"),
str(d.get("error", "")) == "order_not_found")
dres, ok, not_found = _dsl_cancel()
if not ok and not not_found:
time.sleep(self.executor.verify_sleep)
dres, ok, not_found = _dsl_cancel()
if not ok:
data = {"order_id": self.real_dsl_order_id, "res": dres,
"note": ("non in book: probabile trigger durante outage"
if not_found else
"stop forse ORFANO sul book — verificare a mano")}
self._log("REAL_DSL_CANCEL_FAIL", data)
if not not_found:
self._notify("REAL_DSL_CANCEL_FAIL", data)
self.real_dsl_order_id = ""
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
@@ -378,6 +403,11 @@ class StrategyWorker:
slip_bps = ((exit_price / sim_exit - 1) * 1e4
if exit_price and sim_exit else None)
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
self._notify("REAL_DIVERGENCE", {
"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
"real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2),
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)})
self._log("REAL_CLOSE", {
"reason": reason,
"order_id": fill.order_id if fill else tp_order_id,
@@ -487,11 +517,9 @@ class StrategyWorker:
# L'ultima riga del df e' la candela in corso se non e' ancora trascorsa
# la sua durata; il fill resta al prezzo corrente (lag di poll, stress
# lag_close_exit superato in exit-lab). Il buf usa l'ATR della stessa
# barra completata.
ts_arr = df["timestamp"].values.astype("int64")
bar_ms = int(np.median(np.diff(ts_arr[-50:]))) if len(ts_arr) > 1 else 0
now_ms = int(time.time() * 1000)
k = -1 if now_ms >= ts_arr[-1] + bar_ms else -2
# barra completata. Detection condivisa: src.live.bars.
from src.live.bars import last_settled_idx
k = last_settled_idx(df["timestamp"].values)
confirm_close = float(c[k])
buf = self.sl_confirm_atr * float(_atr(df, 14)[k])
if not np.isfinite(buf):
+6 -2
View File
@@ -21,8 +21,12 @@ NOTIFY_EVENTS = {
# prezzo reale puo' gappare, come ETH 2026-06-05 1655->1600)
"PANEL_SHORT", # TSM01/ROT02: panel inner-join troncato sotto il lookback
# richiesto -> tick() salterebbe in SILENZIO (worker inerte)
"FEED_OUTAGE", # N poll consecutivi falliti nel runner: exit non valutati,
# posizioni reali protette solo dal disaster-SL on-book
"FEED_OUTAGE", # N poll consecutivi falliti/degradati nel runner: exit non
# valutati, posizioni reali protette solo dal disaster-SL
"REAL_DIVERGENCE", # |slippage| sim/reale anomalo a open/close (es. spike print
# testnet: sim entra su un prezzo fantasma, il reale sul book)
"REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile
# stop ORFANO sul book -> verificare a mano
}