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:
@@ -66,6 +66,34 @@ zero ordini orfani sul book), conto flat a fine smoke.
|
||||
"flat/risk-off by-design" da "wiring rotto". I multi-asset sono esclusi dalla
|
||||
tabella IN CORSO (assume entry/bars single-leg).
|
||||
|
||||
## Code review multi-agente del giorno stesso (v1.1.7)
|
||||
|
||||
Review a 7 angoli su `8c4e1cd..HEAD` + check trades live. Il candidato top dei finder
|
||||
("stop_market senza trigger") è REFUTATO: cerbero-mcp traduce `price→trigger_price +
|
||||
trigger=mark_price` e in produzione 2 DSL erano armati + 1 ciclo completo pulito.
|
||||
Quattro fix applicati:
|
||||
|
||||
1. **Alert `REAL_DIVERGENCE`** (|slippage sim/reale| ≥ 100bps a open/close). Scoperta
|
||||
dal check trades: alle 10:37 uno **spike print testnet** (candela 10:00 H=65618 con
|
||||
O/C~62400) ha fatto shortare alle 3 fade BTC un close fantasma a 65266.5 — il reale
|
||||
ha fillato correttamente a ~62395 (−440bps) ma il sim ha bookato +2.26 mai esistiti
|
||||
(MR07 reale −0.13). Prima passava in silenzio.
|
||||
2. **`FEED_OUTAGE` anche su feed degradato senza eccezione** (HTTP 200 con candles
|
||||
vuote → i worker saltavano il tick in silenzio e lo streak restava 0). Helper unico
|
||||
`_outage_tick` (fix anche dell'incoerenza chiavi minuti/durata_min).
|
||||
3. **`src/live/bars.py`**: detection forming-bar UNIFICATA (era copiata in 4 punti,
|
||||
con `_check_stale_feed` che hardcodava 1h). È l'invariante di sicurezza di EXIT-16:
|
||||
ora vive in un posto solo, testato (`test_bars.py`).
|
||||
4. **DSL cancel hardening**: retry su errore transitorio + alert Telegram
|
||||
`REAL_DSL_CANCEL_FAIL` se lo stop resta forse ORFANO sul book (prima l'id veniva
|
||||
dimenticato in silenzio → lo stop stantio poteva colpire la posizione successiva);
|
||||
`order_not_found` = probabile trigger durante outage → solo log (il close a valle
|
||||
esce già verified=False). Test con executor finto (`test_real_close_dsl.py`).
|
||||
|
||||
Finding noti NON ancora fixati (in coda): ROT02/TSM01 valutano la candela 1d in
|
||||
formazione al primo poll dopo mezzanotte (stessa classe del fix TR01/Pairs, pre-esistente);
|
||||
engine dei gate copiato fra i 3 script `*_port06_impact.py`; epoche hourly_report hardcoded.
|
||||
|
||||
## Resta in roadmap (settimana 2, OGNUNO dietro gate PORT06/OOS)
|
||||
|
||||
trend_max=3.0 sulle 6 fade → pos pairs 0.15-0.25 per-famiglia → DIP01 EXIT-16
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
+38
-24
@@ -150,13 +150,13 @@ def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
|
||||
risveglio (con il gap % del primo prezzo reale). Una notifica per episodio.
|
||||
NB: SOLO osservabilita' — saltare gli ingressi post-flat PEGGIORA l'edge
|
||||
(testato: la candela-gap e' l'overshoot che la fade fada con profitto)."""
|
||||
import time as _t
|
||||
from src.live.bars import last_settled_idx
|
||||
from src.live.telegram_notifier import notify_event
|
||||
if len(df) < _STALE_BARS + 2:
|
||||
return
|
||||
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
|
||||
# ultima barra COMPLETA: la riga -1 e' la candela in corso finche' non e' trascorsa
|
||||
k = -1 if int(_t.time() * 1000) >= int(df["timestamp"].iloc[-1]) + 3_600_000 else -2
|
||||
# ultima barra COMPLETA (detection condivisa src.live.bars; prima hardcodava 1h)
|
||||
k = last_settled_idx(df["timestamp"].values)
|
||||
i = len(c) + k
|
||||
if i < 1:
|
||||
return
|
||||
@@ -262,10 +262,35 @@ def run(config_path: str = "portfolios.yml"):
|
||||
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
|
||||
# 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 consecutivi (con l'elenco delle posizioni REALI aperte,
|
||||
# protette solo dal disaster-bracket on-book) + notifica di ripresa con la durata.
|
||||
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
|
||||
# "Degradato" include il caso HTTP-200-con-candles-vuote (code review 2026-06-07):
|
||||
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
|
||||
_OUTAGE_POLLS = 5
|
||||
fail_streak = 0
|
||||
|
||||
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
|
||||
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
|
||||
volta per episodio; RIPRESO al primo poll pulito). Ritorna il nuovo streak."""
|
||||
from src.live.telegram_notifier import notify_event
|
||||
if failed:
|
||||
streak += 1
|
||||
if streak == _OUTAGE_POLLS:
|
||||
real_open = sorted(sid for sid, wk in workers.items()
|
||||
if getattr(wk, "real_in_position", False))
|
||||
notify_event("FEED_OUTAGE", {
|
||||
"poll_falliti": streak,
|
||||
"minuti": round(streak * poll / 60),
|
||||
"dettaglio": detail,
|
||||
"posizioni_reali_aperte": ", ".join(real_open) or "nessuna",
|
||||
"nota": "exit NON valutati durante l'outage; "
|
||||
"protezione = disaster-SL on-book sui fade reali"})
|
||||
return streak
|
||||
if streak >= _OUTAGE_POLLS:
|
||||
notify_event("FEED_OUTAGE", {"status": "RIPRESO",
|
||||
"poll_falliti": streak,
|
||||
"minuti": round(streak * poll / 60)})
|
||||
return 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# fetch 1h per asset al lookback massimo richiesto
|
||||
@@ -312,30 +337,19 @@ def run(config_path: str = "portfolios.yml"):
|
||||
rebalance_allocations(ledger, workers, weights)
|
||||
last_day = today
|
||||
ledger.save()
|
||||
if fail_streak >= _OUTAGE_POLLS:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event("FEED_OUTAGE", {
|
||||
"status": "RIPRESO",
|
||||
"poll_falliti": fail_streak,
|
||||
"durata_min": round(fail_streak * poll / 60)})
|
||||
fail_streak = 0
|
||||
# feed degradato senza eccezione: asset richiesti ma senza candele
|
||||
missing = sorted(a for a in asset_days if a not in raw1h)
|
||||
if missing:
|
||||
print(f"[runner] feed incompleto: mancano {missing} (streak {fail_streak + 1})")
|
||||
fail_streak = _outage_tick(bool(missing), fail_streak,
|
||||
detail=f"feed senza candele per: {', '.join(missing)}")
|
||||
except KeyboardInterrupt:
|
||||
ledger.save()
|
||||
print("shutdown")
|
||||
break
|
||||
except Exception as e:
|
||||
fail_streak += 1
|
||||
print(f"[runner] errore: {e} (streak {fail_streak})")
|
||||
if fail_streak == _OUTAGE_POLLS:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
real_open = sorted(sid for sid, wk in workers.items()
|
||||
if getattr(wk, "real_in_position", False))
|
||||
notify_event("FEED_OUTAGE", {
|
||||
"poll_falliti": fail_streak,
|
||||
"minuti": round(fail_streak * poll / 60),
|
||||
"posizioni_reali_aperte": ", ".join(real_open) or "nessuna",
|
||||
"nota": "exit NON valutati durante l'outage; "
|
||||
"protezione = disaster-SL on-book sui fade reali"})
|
||||
print(f"[runner] errore: {e} (streak {fail_streak + 1})")
|
||||
fail_streak = _outage_tick(True, fail_streak, detail=f"eccezione: {e}")
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""src.live.bars — detection condivisa della barra in formazione (lezione EXIT-16)."""
|
||||
import numpy as np
|
||||
|
||||
from src.live.bars import bar_ms_of, last_bar_is_forming, last_settled_idx
|
||||
|
||||
H = 3_600_000
|
||||
|
||||
|
||||
def _ts(n, end_ms):
|
||||
return np.arange(end_ms - (n - 1) * H, end_ms + 1, H, dtype="int64")
|
||||
|
||||
|
||||
def test_bar_ms_median():
|
||||
assert bar_ms_of(_ts(50, 10 * H)) == H
|
||||
assert bar_ms_of([1]) == 0 # troppo corta per stimare
|
||||
|
||||
|
||||
def test_forming_vs_settled():
|
||||
now = 1_000_000 * H
|
||||
ts = _ts(60, now) # ultima barra appena aperta
|
||||
assert last_bar_is_forming(ts, now_ms=now + 1) # in corso
|
||||
assert last_settled_idx(ts, now_ms=now + 1) == -2
|
||||
assert not last_bar_is_forming(ts, now_ms=now + H) # durata trascorsa
|
||||
assert last_settled_idx(ts, now_ms=now + H) == -1
|
||||
|
||||
|
||||
def test_degenerate_series_defaults_to_settled():
|
||||
assert not last_bar_is_forming([], now_ms=0)
|
||||
assert last_settled_idx([123], now_ms=0) == -1 # bar_ms non stimabile -> -1
|
||||
@@ -0,0 +1,103 @@
|
||||
"""_real_close: cancel del disaster-SL (retry + alert orfano) e alert REAL_DIVERGENCE.
|
||||
|
||||
Usa un executor finto: nessuna rete, nessun ordine reale.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.live.execution import Fill
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
|
||||
|
||||
class FakeExec:
|
||||
verify_polls = 1
|
||||
verify_sleep = 0.0
|
||||
disaster_sl_pct = 0.30
|
||||
|
||||
def __init__(self, cancel_responses):
|
||||
self.cancel_calls = []
|
||||
self._responses = list(cancel_responses)
|
||||
|
||||
def cancel_order(self, oid):
|
||||
self.cancel_calls.append(oid)
|
||||
return self._responses.pop(0) if self._responses else {"state": "error", "error": "boom"}
|
||||
|
||||
def resting_fills(self, instrument, oid):
|
||||
return 0.0, None, 0.0
|
||||
|
||||
def close_amount(self, instrument, side, amount, label=None):
|
||||
return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0,
|
||||
"oid-close", "filled", True)
|
||||
|
||||
|
||||
def _worker(tmp_path, fake, monkeypatch, notified):
|
||||
monkeypatch.setattr("src.live.strategy_worker.notify_event",
|
||||
lambda ev, data=None: notified.append(ev))
|
||||
w = StrategyWorker(strategy=SimpleNamespace(name="FAKE", fee_rt=0.001),
|
||||
asset="BTC", tf="1h", capital=100.0, data_dir=tmp_path,
|
||||
executor=fake, exec_instrument="BTC_USDC-PERPETUAL")
|
||||
w.real_in_position = True
|
||||
w.real_side = "buy"
|
||||
w.real_amount = 0.001
|
||||
w.real_entry_price = 100.0
|
||||
w.real_entry_notional = 0.1
|
||||
w.real_dsl_order_id = "DSL-1"
|
||||
return w
|
||||
|
||||
|
||||
def test_dsl_cancel_untriggered_is_success(tmp_path, monkeypatch):
|
||||
notified = []
|
||||
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(100.0, "time_limit", 0.0)
|
||||
assert fake.cancel_calls == ["DSL-1"] # nessun retry
|
||||
assert "REAL_DSL_CANCEL_FAIL" not in notified
|
||||
assert w.real_dsl_order_id == ""
|
||||
|
||||
|
||||
def test_dsl_cancel_not_found_logs_but_no_alert(tmp_path, monkeypatch):
|
||||
# order_not_found = probabile trigger durante outage: log, NIENTE Telegram, no retry
|
||||
notified = []
|
||||
fake = FakeExec([{"order_id": "DSL-1", "state": "error", "error": "order_not_found"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(100.0, "time_limit", 0.0)
|
||||
assert fake.cancel_calls == ["DSL-1"]
|
||||
assert "REAL_DSL_CANCEL_FAIL" not in notified
|
||||
|
||||
|
||||
def test_dsl_cancel_transient_error_retries_then_succeeds(tmp_path, monkeypatch):
|
||||
notified = []
|
||||
fake = FakeExec([{"state": "error", "error": "timeout"},
|
||||
{"order_id": "DSL-1", "state": "cancelled"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(100.0, "time_limit", 0.0)
|
||||
assert fake.cancel_calls == ["DSL-1", "DSL-1"] # retry eseguito
|
||||
assert "REAL_DSL_CANCEL_FAIL" not in notified
|
||||
|
||||
|
||||
def test_dsl_cancel_persistent_error_alerts_orphan(tmp_path, monkeypatch):
|
||||
notified = []
|
||||
fake = FakeExec([{"state": "error", "error": "timeout"},
|
||||
{"state": "error", "error": "timeout"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(100.0, "time_limit", 0.0)
|
||||
assert fake.cancel_calls == ["DSL-1", "DSL-1"]
|
||||
assert "REAL_DSL_CANCEL_FAIL" in notified # stop forse orfano -> Telegram
|
||||
|
||||
|
||||
def test_real_divergence_alert_on_close(tmp_path, monkeypatch):
|
||||
# fill reale 100.0 vs sim_exit 105.0 -> ~-476bps -> REAL_DIVERGENCE
|
||||
notified = []
|
||||
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(105.0, "take_profit", 0.5)
|
||||
assert "REAL_DIVERGENCE" in notified
|
||||
|
||||
|
||||
def test_no_divergence_alert_when_fill_matches(tmp_path, monkeypatch):
|
||||
notified = []
|
||||
fake = FakeExec([{"order_id": "DSL-1", "state": "untriggered"}])
|
||||
w = _worker(tmp_path, fake, monkeypatch, notified)
|
||||
w._real_close(100.05, "take_profit", 0.0) # ~-5bps: sotto soglia
|
||||
assert "REAL_DIVERGENCE" not in notified
|
||||
Reference in New Issue
Block a user