Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515193a203 | |||
| 612f2bfced | |||
| a2d581691a | |||
| 5fe53841e9 | |||
| e0257c6c88 |
@@ -0,0 +1,74 @@
|
||||
# 2026-06-12 — ACCEL50: cosa accelera davvero verso €50/giorno
|
||||
|
||||
**Domanda.** Quali strategie/leve accorciano il tempo per arrivare a €50/g da ~€2k?
|
||||
Script: `scripts/analysis/accel50_research.py`.
|
||||
|
||||
## Il quadro onesto
|
||||
|
||||
A €2k, anche col CAGR OOS del PORT06 (~111% a lev 2), il PnL atteso è ~€4/g: il
|
||||
collo di bottiglia NON è l'edge (Sharpe OOS 10), è la **taglia**. Le vie testate,
|
||||
in ordine di impatto:
|
||||
|
||||
## 1. LEVA — l'acceleratore dominante (nessuna ricerca nuova)
|
||||
|
||||
Frontiera su daily return canonici PORT06 (scala lineare, fee pro-quota):
|
||||
|
||||
| lev | CAGR full | DD full | CAGR OOS | DD OOS | anni a €50/g da 2k |
|
||||
|-----|-----------|---------|----------|--------|--------------------|
|
||||
| 2 (attuale) | 74% | 3.5% | 111% | 1.5% | 3.3 |
|
||||
| 3 | 128% | 5.2% | 206% | 2.2% | 1.9 |
|
||||
| 4 | 200% | 6.9% | 343% | 2.9% | 1.2 |
|
||||
| 5 | 293% | 8.6% | 539% | 3.7% | 0.9 |
|
||||
|
||||
Anche scontando l'OOS del 50% (regime calmo, caveat noto), lev 3-4 dimezza i
|
||||
tempi tenendo il DD sotto il 10%. Caveat: il modello è lineare — non cattura
|
||||
margine, code grasse, slippage che cresce col notional, e gli sleeve senza stop
|
||||
(PAIRS/SH01) a leva alta hanno code peggiori del modello. Proposta sobria:
|
||||
**lev 2→3 subito** (DD full 5.2% = ancora metà del PORT02 storico), rivalutare
|
||||
4 dopo un mese di ledger reale pulito.
|
||||
|
||||
## 2. FADE 15m — la candidata nuova che PASSA il probe
|
||||
|
||||
MR01/02/07 a 15m, parametri live 1h non ri-tunati (anti-overfit), fee 0.10% RT:
|
||||
|
||||
- **Tutti e 6 gli sleeve positivi**, OOS 2025-26 positivo ovunque, fee 2x OK
|
||||
(Sh 1.6-2.9 — margine ampio).
|
||||
- **BTC 15m domina il suo 1h**: MR01 Sh 3.37 vs 2.76 con META' del DD
|
||||
(15.1% vs 31.7%); MR02 3.49 vs 3.29 (DD 10.8 vs 18.7).
|
||||
- ETH 15m leggermente sotto il 1h in Sharpe ma OOS molto più grande in valore
|
||||
assoluto (4x trade = compounding più veloce; MR02 +22052 vs +9560).
|
||||
- Infrastruttura live 15m GIA' esistente (fetch sub-orario del BLEND pairs).
|
||||
|
||||
**Prossimi passi obbligati prima del deploy** (metodologia standard):
|
||||
gate PORT06 con correlazione 15m↔1h (se ~1 sostituire, se bassa aggiungere),
|
||||
griglia parametri al 15m, validazione worker, caveat flat ETH 15m (14-30%
|
||||
storico — per le fade single-leg il fill flat è meno tossico che per i pairs,
|
||||
ma va guardato con flat-aware engine).
|
||||
|
||||
## 3. PAIRS nuove — BOCCIATE (stale-print illusion, di nuovo)
|
||||
|
||||
Sweep onesto delle 19 coppie mai testate (config universale pre-registrata
|
||||
n=50 z2.0/0.5 max72): 8 candidate con Sh 1.5-4.3... MA le gambe alt hanno
|
||||
88-98% barre flat (ADA 98%, LTC 97%, DOGE 91%, XRP 88%, BNB 88%) e con
|
||||
`flat_skip=True` muoiono quasi tutte (BTC/ADA 4.33→0.17, ETH/DOGE 3.79→0.46;
|
||||
migliore superstite ETH/XRP 1.34 < le 5 deployate). Identica classe di
|
||||
illusione del XEX su DOGE/SOL (stessa giornata, `xex_divergence_research.py`).
|
||||
**PAXG idem**: 92% flat su Deribit → chiuso anche il ramo "oro".
|
||||
|
||||
NB: questo getta anche una luce nuova sulle gambe alt dei pairs GIA' deployati
|
||||
(ADA/LTC/SOL hanno flat share altissime nel parquet) — il loro ledger reale
|
||||
shadow è il banco di prova giusto e finora regge, ma teniamolo d'occhio.
|
||||
|
||||
## 4. CAPITALE — domina tutto
|
||||
|
||||
A config attuale: €50/g ≈ €24k di capitale. Ogni € aggiunto accorcia
|
||||
linearmente; nessuna ricerca batte un deposito. In più, a ~€20k si sbloccano
|
||||
in esecuzione reale i 4 book multi-asset oggi solo paper (TR01/ROT02/TSM01/XS01).
|
||||
|
||||
## Sintesi operativa
|
||||
|
||||
Ordine d'impatto: **capitale > leva (2→3) > fade 15m (da gateare) >>** tutto il
|
||||
resto. Le vie "nuova strategia esotica" (pairs nuove, PAXG, XEX) sono tutte
|
||||
morte oggi sotto il test di esecuzione realistica — la lezione del giorno è che
|
||||
su questo testnet ogni edge va validato col **book/flat-aware engine** prima di
|
||||
crederci.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 2026-06-12 — XEX: discordanze Deribit testnet vs Hyperliquid
|
||||
|
||||
**Domanda.** I prezzi Deribit (testnet, dove eseguiamo) e Hyperliquid (feed
|
||||
realistico, proxy della realtà) divergono? La divergenza è usabile per fare trade?
|
||||
|
||||
**Strumenti comuni validati:** 9 base-coin (ADA, AVAX, BNB, BTC, DOGE, ETH, PAXG,
|
||||
SOL, TRUMP). Analisi su BTC/ETH (inverse, liquidi) + SOL/DOGE (lineari USDC).
|
||||
Script: `scripts/analysis/xex_divergence_research.py`.
|
||||
|
||||
## Misure (1h, 2026-03-01 → 2026-06-12, ~2470 barre)
|
||||
|
||||
| Coin | spread medio | std | half-life | flat Deribit | Δbook−HL live |
|
||||
|------|--------------|-----|-----------|--------------|----------------|
|
||||
| BTC | −0.29% | 1.25% | 7.3h | 6% | **−0.97%** |
|
||||
| ETH | −0.55% | 1.33% | 5.1h | 9% | **−1.54…−2.16%** |
|
||||
| SOL | −0.38% | 3.60% | 5.2h | 35% | −0.05% (allineato) |
|
||||
| DOGE | +0.34% | 1.88% | 4.2h | 87% | +0.16% (allineato) |
|
||||
|
||||
Per standard mainnet questi spread sono enormi (reale <0.05%): la divergenza è
|
||||
l'artefatto del feed testnet che periodicamente si stacca dalla realtà e rientra.
|
||||
|
||||
## Findings
|
||||
|
||||
1. **Lo spread è mean-reverting e il gap viene chiuso ANCHE da Deribit** (il lato
|
||||
che possiamo tradare): beta del ritorno futuro Deribit sullo spread negativo e
|
||||
crescente con l'orizzonte (ETH −0.36, BTC −0.23 a 24h). Non è solo HL che si
|
||||
muove.
|
||||
2. **Trappola smascherata — stale print arb.** Il backtest su DOGE dava Sharpe
|
||||
6.7 (OOS 9.1!) e SOL 2.7, MA il book live di entrambi sta attaccato a HL
|
||||
(±0.16%) mentre i print restano vecchi (DOGE 87% barre flat): l'edge era
|
||||
**finzione** — i fill reali avverrebbero al prezzo vero, non al print stantio.
|
||||
Stessa classe di illusione del look-ahead squeeze: il backtest compra un
|
||||
prezzo a cui nessuno fa fill.
|
||||
3. **Su BTC/ETH inverse la dislocazione è REALE: è il book stesso a essere
|
||||
spostato** (misurato live: bid/ask −0.97% / −1.54…−2.16% sotto HL con depth
|
||||
>$1M sui primi 5 livelli, spread bid/ask 1-3 bps). Lì si può davvero comprare
|
||||
sotto/vendere sopra la realtà.
|
||||
4. **Edge netto moderato e timing-sensitive** (fee 0.10% RT, entry |s|≥1%, exit
|
||||
|s|≤0.25% o 24 barre): BTC FULL Sh 1.0 / OOS 2.05; ETH FULL 2.28 / OOS 1.37.
|
||||
Con entry ritardato di 1 barra (stress staleness) BTC FULL→0, ETH OOS→0.1:
|
||||
con half-life di 3-7 ore, un'ora di ritardo erode quasi tutto. Un'eventuale
|
||||
implementazione live deve leggere il **book in tempo reale** (non il close 1h)
|
||||
— a quel punto il lag reale è minuti, non un'ora, e l'edge eseguibile sta fra
|
||||
lag0 e lag1.
|
||||
|
||||
## Segnale live al momento dell'analisi
|
||||
|
||||
ETH book Deribit **−2.16%** sotto HL (z=−1.7, 6.7° percentile storico), BTC
|
||||
−0.97%: entrambi in zona "long Deribit" per il fade dello spread. (Solo
|
||||
osservazione: nessun deploy senza gate.)
|
||||
|
||||
## Verdetto e prossimi passi
|
||||
|
||||
- Discordanza tradabile trovata: **fade dello spread D/H su BTC/ETH-PERPETUAL,
|
||||
segnale dal book, soglia ~1%, exit a convergenza o 24h**. Da NON estendere a
|
||||
DOGE/SOL (illusione stale-print).
|
||||
- È un **edge di testnet** (non trasferibile a mainnet): legittimo per il
|
||||
paper/shadow corrente, ma va dichiarato per quello che è.
|
||||
- Prima di qualsiasi deploy, metodologia standard: gate PORT06 (correlazione con
|
||||
le fade esistenti, che già fadano in parte questi stessi spike testnet —
|
||||
rischio doppio conteggio), robustezza griglia, validazione worker.
|
||||
|
||||
Script: `scripts/analysis/xex_divergence_research.py` (fetch v2 due exchange,
|
||||
tabella convergenza, backtest lag0/lag1, book reality-check live).
|
||||
+11
-7
@@ -8,7 +8,10 @@ overrides:
|
||||
# base.py:load_active_portfolio) → va ridichiarato COMPLETO. Il cap SHAPE
|
||||
# 0.0588 (mitigazione coda SH01, 2026-06-05) era stato perso per questo.
|
||||
caps: {PAIRS: 0.33, SHAPE: 0.0588}
|
||||
leverage: 2 # sobrio per il live reale
|
||||
# Leva 3x (2026-06-12, scelta utente, da frontiera ACCEL50: OOS CAGR 111->206%,
|
||||
# FULL DD 3.5->5.2% nel modello lineare; vedi scripts/analysis/accel50_research.py
|
||||
# e docs/diary/2026-06-12-accel50.md). Era 2 ("sobrio") dal primo deploy live.
|
||||
leverage: 3
|
||||
rebalance: 1D
|
||||
poll_seconds: 60
|
||||
# SLEEVE PAPER (2026-06-08): fuori dal pool/pesi/ledger — i €2000 si dividono SOLO
|
||||
@@ -22,12 +25,13 @@ overrides:
|
||||
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego
|
||||
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
|
||||
position_size: 0.5
|
||||
# Override per-famiglia (chiave = weighting.family_of). PAIRS 0.20 (2026-06-07):
|
||||
# famiglia SENZA stop, validata a esposizione 0.45 (pos 0.15 lev 3) — a 0.5×2=1.0
|
||||
# girava a ~2.2x il validato (ETH/BTC DD grezzo 78%; ADA/ETH live -4.26% sleeve in
|
||||
# un trade). A 0.20×2=0.40 ≈ validato: PORT06 OOS DD 3.40→1.26%, costo OOS Sharpe
|
||||
# 9.05→8.43 (assicurazione, come il cap SHAPE). Gate: pairspos_port06_impact.py.
|
||||
position_size_family: {PAIRS: 0.20}
|
||||
# Override per-famiglia (chiave = weighting.family_of). PAIRS: famiglia SENZA
|
||||
# stop, validata a esposizione 0.45 (pos 0.15 lev 3) — il gate 2026-06-07
|
||||
# (pairspos_port06_impact.py) la fisso' a 0.40 di esposizione (0.20 × lev 2):
|
||||
# PORT06 OOS DD 3.40→1.26%, costo OOS Sharpe 9.05→8.43. Col passaggio a lev 3
|
||||
# (2026-06-12) il pos scende a 0.13 per CONSERVARE la stessa esposizione
|
||||
# (0.13×3≈0.40): la leva accelera le famiglie con stop, non i pairs no-stop.
|
||||
position_size_family: {PAIRS: 0.13}
|
||||
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo).
|
||||
# I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) +
|
||||
# DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""ACCEL50 — Ricerca acceleratori verso l'obiettivo €50/giorno (2026-06-12).
|
||||
|
||||
Domanda: quali strategie/leve portano PIU' VELOCEMENTE a €50/g partendo da ~€2k?
|
||||
Diario: docs/diary/2026-06-12-accel50.md. Esiti:
|
||||
|
||||
1. LEVA su PORT06 (acceleratore dominante, zero ricerca nuova).
|
||||
La frontiera (scala lineare dei daily return canonici, fee pro-quota) mostra
|
||||
che a Sharpe ~7-10 il vincolo non e' il rischio ma la taglia: lev 2->4 porta
|
||||
gli anni-a-target da 3.3 a 1.2 con FULL DD 3.5->6.9%. Vedi lev_frontier().
|
||||
|
||||
2. FADE 15m (candidata NUOVA, validazione preliminare PASSATA).
|
||||
MR01/MR02/MR07 a 15m con i parametri live 1h (trend_max=3, ema_long=200,
|
||||
sl_confirm_atr=0.5, fee 0.10% RT): tutti e 6 gli sleeve positivi, OOS
|
||||
2025-26 positivo ovunque (spesso > del 1h: 4x trade = compounding piu'
|
||||
rapido), reggono fee 2x (Sh 1.6-2.9). BTC 15m MIGLIORA il 1h (MR01 Sh
|
||||
3.37 vs 2.76 con meta' DD). Prossimo passo obbligato: gate PORT06
|
||||
(correlazione col gemello 1h, parita' worker — infra 15m gia' esistente
|
||||
dal BLEND pairs). Vedi fade15m_probe().
|
||||
|
||||
3. PAIRS NUOVE: BOCCIATE (stale-print illusion).
|
||||
Lo sweep delle 19 coppie mai testate (config universale pre-registrata)
|
||||
dava 8 candidate con Sharpe 1.5-4.3, MA le gambe alt hanno 88-98% di barre
|
||||
flat (LTC 97%, ADA 98%, DOGE 91%, XRP 88%, BNB 88%) e con flat_skip=True
|
||||
(fill solo su barre pulite) muoiono quasi tutte (BTC/ADA 4.33->0.17,
|
||||
ETH/DOGE 3.79->0.46). Migliore superstite ETH/XRP a 1.34: inferiore alle
|
||||
5 deployate -> niente. Stessa classe di illusione del XEX su DOGE/SOL
|
||||
(vedi xex_divergence_research.py). PAXG idem: 92% flat su Deribit.
|
||||
|
||||
4. CAPITALE: a config attuale servono ~€24k per €50/g; ogni € aggiunto
|
||||
accorcia linearmente (non e' una strategia ma domina ogni altra leva).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
def lev_frontier() -> None:
|
||||
"""Frontiera di leva su PORT06: CAGR/DD/Sharpe e anni-a-€50/g per lev 1-6.
|
||||
Modello: scala lineare dei daily return del backtest canonico (strumenti
|
||||
lineari, fee proporzionali al notional). NON modella margine/code grasse."""
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from scripts.analysis.combine_portfolio import port_returns, SPLIT
|
||||
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
|
||||
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
eq = all_sleeve_equities()
|
||||
members = {sid: eq[sid] for sid in p.sleeve_ids}
|
||||
w = p.weight_vector(sleeve_returns_df(p.sleeve_ids))
|
||||
base = port_returns(members, w) # == live a lev 2 (parita' validata)
|
||||
|
||||
def dd(x):
|
||||
c = (1 + x).cumprod()
|
||||
return ((c - c.cummax()) / c.cummax()).min() * 100
|
||||
|
||||
def cagr(x):
|
||||
c = (1 + x).cumprod()
|
||||
return ((c.iloc[-1]) ** (365 / len(x)) - 1) * 100
|
||||
|
||||
print("lev CAGR_full% DD_full% CAGR_oos% DD_oos% K_per_50/g anni_da_2k")
|
||||
for f, lev in [(0.5, 1), (1.0, 2), (1.5, 3), (2.0, 4), (2.5, 5), (3.0, 6)]:
|
||||
r = base * f
|
||||
roos = r.iloc[SPLIT:]
|
||||
co = cagr(roos)
|
||||
daily = (1 + co / 100) ** (1 / 365) - 1
|
||||
k = 50 / daily if daily > 0 else float("inf")
|
||||
anni = np.log(k / 2020) / np.log(1 + co / 100) if co > 0 else float("inf")
|
||||
print(f"{lev:>3} {cagr(r):>11.0f} {dd(r):>9.2f} {co:>10.0f} {dd(roos):>8.2f} "
|
||||
f"{k:>11,.0f} {max(anni, 0):>11.1f}")
|
||||
|
||||
|
||||
def fade15m_probe() -> None:
|
||||
"""MR01/02/07 a 15m vs 1h, parametri live, fee 0.10% e stress 2x."""
|
||||
import importlib.util
|
||||
import inspect
|
||||
from src.strategies.base import Strategy
|
||||
|
||||
LIVEP = dict(trend_max=3.0, ema_long=200, sl_confirm_atr=0.5)
|
||||
paths = {
|
||||
"MR01": "scripts/strategies/MR01_bollinger_fade.py",
|
||||
"MR02": "scripts/strategies/MR02_donchian_fade.py",
|
||||
"MR07": "scripts/strategies/MR07_return_reversal.py",
|
||||
}
|
||||
for code, rel in paths.items():
|
||||
spec = importlib.util.spec_from_file_location(code.lower(), PROJECT_ROOT / rel)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
cls = next(o for _, o in vars(m).items()
|
||||
if inspect.isclass(o) and issubclass(o, Strategy) and o.__module__ == m.__name__)
|
||||
s = cls()
|
||||
for asset in ("BTC", "ETH"):
|
||||
line = f"{code} {asset}: "
|
||||
for tf in ("1h", "15m"):
|
||||
r = s.backtest(asset, tf, **LIVEP)
|
||||
if r is None:
|
||||
line += f"{tf}: no-sig | "
|
||||
continue
|
||||
oos = sum(y.pnl for y in r.yearly if y.year >= 2025)
|
||||
old = s.fee_rt
|
||||
s.fee_rt = 0.002
|
||||
r2 = s.backtest(asset, tf, **LIVEP)
|
||||
s.fee_rt = old
|
||||
line += (f"{tf}: Sh{r.sharpe:5.2f} DD{r.max_dd:5.1f}% n={r.trades:4d} "
|
||||
f"oos25-26={oos:+8.0f} fee2x_Sh{r2.sharpe:5.2f} | ")
|
||||
print(line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== 1. Frontiera di leva PORT06 ===")
|
||||
lev_frontier()
|
||||
print("\n=== 2. Fade 15m probe ===")
|
||||
fade15m_probe()
|
||||
@@ -29,7 +29,8 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
from src.live.execution import contract_spec
|
||||
from src.live.books import real_books, account_net # fonte UNICA dei libri (usata
|
||||
from src.live.books import real_books, account_net, expected_resting, PAPER
|
||||
# fonte UNICA dei libri (usata
|
||||
# anche dal guard del netting)
|
||||
|
||||
RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
|
||||
@@ -53,15 +54,78 @@ def compute_drift(client: CerberoClient | None = None) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _book_orders(client: CerberoClient) -> dict[str, dict]:
|
||||
"""Ordini aperti sul conto, merge type='all' + 'trigger_all' per order_id
|
||||
(Deribit puo' omettere i trigger untriggered da 'all')."""
|
||||
orders: dict[str, dict] = {}
|
||||
for typ in ("all", "trigger_all"):
|
||||
for o in client.get_open_orders(currency="USDC", type=typ) or []:
|
||||
oid = str(o.get("order_id") or "")
|
||||
if oid:
|
||||
orders[oid] = o
|
||||
return orders
|
||||
|
||||
|
||||
def _resting_filled(client: CerberoClient, instrument: str, order_id: str) -> float:
|
||||
"""Amount fillato di un ordine resting dal trade history (fonte autorevole)."""
|
||||
try:
|
||||
return sum(float(t.get("amount", 0) or 0)
|
||||
for t in client.get_trade_history(limit=100,
|
||||
instrument_name=instrument)
|
||||
if str(t.get("order_id")) == str(order_id))
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def compute_resting_drift(client: CerberoClient | None = None) -> list[dict]:
|
||||
"""Reconcile degli ordini RESTING (estensione 2026-06-12, dopo il caso MR02_BTC:
|
||||
TP resting fillato sul book di notte + disaster-SL sparito, scoperti solo al
|
||||
close sim ore dopo). Tre classi di anomalia:
|
||||
|
||||
FILLED_UNBOOKED l'ordine atteso non e' in book e ha fill nel trade history
|
||||
mentre il worker si crede ancora in posizione (il caso MR02)
|
||||
MISSING l'ordine atteso non e' in book e non ha fill (cancellato da
|
||||
altri/exchange; per il DSL triggered il fill ha un order_id
|
||||
NUOVO -> qui appare MISSING e il drift posizioni completa)
|
||||
STALE ordine in book con label di un nostro worker ma NON atteso
|
||||
dai libri (worker flat/morto: fillerebbe a sorpresa)
|
||||
"""
|
||||
client = client or CerberoClient()
|
||||
expected = expected_resting()
|
||||
book = _book_orders(client)
|
||||
rows: list[dict] = []
|
||||
for e in expected:
|
||||
if e["order_id"] in book:
|
||||
rows.append({**e, "status": "OK"})
|
||||
continue
|
||||
filled = _resting_filled(client, e["instrument"], e["order_id"])
|
||||
rows.append({**e, "status": "FILLED_UNBOOKED" if filled > 0 else "MISSING",
|
||||
"filled": filled})
|
||||
exp_ids = {e["order_id"] for e in expected}
|
||||
workers = {p.name for p in PAPER.glob("*") if p.is_dir()}
|
||||
for oid, o in book.items():
|
||||
if oid not in exp_ids and str(o.get("label") or "") in workers:
|
||||
rows.append(dict(worker=o.get("label"), instrument=o.get("instrument"),
|
||||
order_id=oid, kind=o.get("order_type"),
|
||||
status="STALE"))
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
rows = compute_drift()
|
||||
client = CerberoClient()
|
||||
rows = compute_drift(client)
|
||||
resting = compute_resting_drift(client)
|
||||
bad = [r for r in rows if not r["ok"]]
|
||||
if bad:
|
||||
bad_rest = [r for r in resting if r["status"] != "OK"]
|
||||
if bad or bad_rest:
|
||||
# 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)...")
|
||||
print(f"anomalie (pos={len(bad)} resting={len(bad_rest)}): "
|
||||
f"ricontrollo fra {RECHECK_SLEEP}s (anti-race)...")
|
||||
time.sleep(RECHECK_SLEEP)
|
||||
rows = compute_drift()
|
||||
rows = compute_drift(client)
|
||||
resting = compute_resting_drift(client)
|
||||
bad = [r for r in rows if not r["ok"]]
|
||||
bad_rest = [r for r in resting if r["status"] != "OK"]
|
||||
|
||||
print(f"{'strumento':<22}{'libri':>10}{'orfani':>9}{'atteso':>10}{'conto':>10}{'drift':>10} esito")
|
||||
for r in rows:
|
||||
@@ -69,21 +133,53 @@ def main():
|
||||
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:
|
||||
print(f"\n{'resting':<45}{'kind':>6} {'order_id':<22} stato")
|
||||
for r in resting:
|
||||
print(f"{r['worker']:<45}{r['kind']:>6} {r['order_id']:<22} "
|
||||
f"{'OK' if r['status'] == 'OK' else '⚠️ ' + r['status']}")
|
||||
if not resting:
|
||||
print("(nessun ordine resting atteso ne' in book)")
|
||||
|
||||
print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest)
|
||||
else f"⚠️ DRIFT PERSISTENTE (pos={len(bad)} resting={len(bad_rest)})")
|
||||
|
||||
if (bad or bad_rest) 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 bad:
|
||||
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")
|
||||
if bad_rest:
|
||||
notify_event("RESTING_DRIFT", {
|
||||
"ordini": [{k: r.get(k) for k in ("worker", "kind", "order_id", "status",
|
||||
"filled")} for r in bad_rest],
|
||||
"note": ("FILLED_UNBOOKED = resting fillato col worker ancora in posizione "
|
||||
"(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; "
|
||||
"STALE = in book senza libro corrispondente")})
|
||||
print("[telegram] alert RESTING_DRIFT inviato")
|
||||
return bool(bad or bad_rest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(1 if main() else 0)
|
||||
# Il run delle 11:40 del 2026-06-12 e' morto in silenzio su un 502 (Deribit/gateway
|
||||
# giu') -> il guardiano che non suona e' indistinguibile dal "tutto ok". Su errore:
|
||||
# alert RECONCILE_FAIL (con --telegram) + exit 2.
|
||||
try:
|
||||
sys.exit(1 if main() else 0)
|
||||
except Exception as exc:
|
||||
print(f"RECONCILE_FAIL: {exc}")
|
||||
if "--telegram" in sys.argv:
|
||||
try:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event("RECONCILE_FAIL", {
|
||||
"errore": str(exc)[:200],
|
||||
"note": "reconciler NON eseguito: conto non verificato in quest'ora"})
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(2)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""XEX — Discordanze cross-exchange Deribit (testnet) vs Hyperliquid.
|
||||
|
||||
Ricerca 2026-06-12. Domanda: il prezzo Deribit testnet si discosta da quello
|
||||
Hyperliquid (proxy della realta'); lo scostamento e' tradabile dal nostro conto?
|
||||
|
||||
Esito (vedi diario docs/diary/2026-06-12-xex-divergence.md):
|
||||
- Lo spread log(D/H) e' enorme per standard reali (std 0.9-4.5%) e MEAN-REVERTING
|
||||
(AR1 rho 0.77-0.94, half-life 2.7-12 barre 1h).
|
||||
- Il gap viene chiuso da ENTRAMBI i lati: beta del ritorno futuro Deribit sullo
|
||||
spread e' negativo e cresce con l'orizzonte (ETH -0.36, BTC -0.23 a 24h)
|
||||
-> tradabile dal lato Deribit (il nostro conto).
|
||||
- TRAPPOLA SMASCHERATA: su DOGE/SOL (lineari USDC illiquidi, 87%/35% barre flat)
|
||||
l'edge del backtest (Sharpe 6.7/2.7) e' FINZIONE da print stantii: il BOOK
|
||||
live sta attaccato a HL (+0.16%/-0.05%) mentre i print restano vecchi.
|
||||
Su BTC/ETH inverse invece il BOOK STESSO e' dislocato (-0.94%/-2.16% misurati
|
||||
live con depth >$1M) -> la' la discordanza e' reale ed eseguibile.
|
||||
- Candidati: solo BTC-PERPETUAL / ETH-PERPETUAL. Edge netto (fee 0.10% RT)
|
||||
moderato e sensibile al timing (half-life corta: lag 1h di entry lo erode).
|
||||
NON deployare senza: segnale dal BOOK (non dal close), poll fitto, gate PORT06.
|
||||
|
||||
NB: e' un edge da TESTNET (la dislocazione e' l'artefatto del feed testnet che
|
||||
rientra verso la realta'): non trasferibile a mainnet, dove lo spread D/H reale
|
||||
e' <0.05%. Utile per il paper/shadow corrente, non per capitale vero.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
|
||||
FEE_RT = 0.001
|
||||
START, END = "2026-03-01", "2026-06-12"
|
||||
SPLIT = pd.Timestamp("2026-05-10", tz="UTC")
|
||||
PAIRS = [
|
||||
("BTC", "BTC-PERPETUAL"),
|
||||
("ETH", "ETH-PERPETUAL"),
|
||||
("SOL", "SOL_USDC-PERPETUAL"),
|
||||
("DOGE", "DOGE_USDC-PERPETUAL"),
|
||||
]
|
||||
|
||||
|
||||
def fetch(c: CerberoClient, coin: str, d_inst: str) -> pd.DataFrame:
|
||||
def hist(ex: str, inst: str) -> pd.Series:
|
||||
rows = c.get_historical_v2(inst, START, END, interval="1h", exchange=ex)
|
||||
df = pd.DataFrame(rows)
|
||||
df["ts"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df = df.set_index("ts")
|
||||
return df.loc[~df.index.duplicated(), "close"]
|
||||
|
||||
return pd.DataFrame({"d": hist("deribit", d_inst), "h": hist("hyperliquid", coin)}).dropna()
|
||||
|
||||
|
||||
def convergence_table(j: pd.DataFrame) -> None:
|
||||
"""Chi chiude il gap: regressione spread[i] -> ritorno futuro per venue."""
|
||||
s = np.log(j["d"] / j["h"])
|
||||
for hz in (1, 6, 12, 24):
|
||||
rd = np.log(j["d"].shift(-hz) / j["d"])
|
||||
rh = np.log(j["h"].shift(-hz) / j["h"])
|
||||
m = s.notna() & rd.notna() & rh.notna()
|
||||
bd = np.polyfit(s[m], rd[m], 1)[0]
|
||||
bh = np.polyfit(s[m], rh[m], 1)[0]
|
||||
print(f" h={hz:>2}: beta_D={bd:+.2f} (lato tradabile) beta_H={bh:+.2f}")
|
||||
|
||||
|
||||
def backtest(j: pd.DataFrame, entry: float = 1.0, exit_: float = 0.25,
|
||||
max_bars: int = 24, fee: float = FEE_RT, lag: int = 0):
|
||||
"""Fade dello spread sul solo lato Deribit. Entry al close (o close+lag per
|
||||
stress staleness), skip barre flat, exit a |s|<=exit_ o max_bars."""
|
||||
d, h = j["d"].values, j["h"].values
|
||||
s = np.log(d / h) * 100
|
||||
dret = np.r_[0.0, np.diff(np.log(d))]
|
||||
flat = np.r_[True, dret[1:] == 0]
|
||||
pos, entry_i, pnl, pend = 0, -1, 0.0, None
|
||||
eq, trades = [0.0], []
|
||||
for i in range(1, len(j)):
|
||||
r = 0.0
|
||||
if pos != 0:
|
||||
r = pos * dret[i]
|
||||
pnl += r
|
||||
if abs(s[i]) <= exit_ or (i - entry_i) >= max_bars:
|
||||
r -= fee / 2
|
||||
trades.append(pnl - fee)
|
||||
pos, pnl = 0, 0.0
|
||||
if pend is not None and pend[0] == i:
|
||||
if pos == 0:
|
||||
pos, entry_i, pnl = pend[1], i, 0.0
|
||||
r -= fee / 2
|
||||
pend = None
|
||||
if pos == 0 and pend is None and abs(s[i]) >= entry and not flat[i]:
|
||||
if lag == 0:
|
||||
pos, entry_i, pnl = -np.sign(s[i]), i, 0.0
|
||||
r -= fee / 2
|
||||
else:
|
||||
pend = (i + lag, -np.sign(s[i]))
|
||||
eq.append(r)
|
||||
return pd.Series(eq, index=j.index), np.array(trades)
|
||||
|
||||
|
||||
def report(rets: pd.Series, trades: np.ndarray, label: str) -> None:
|
||||
ann = np.sqrt(24 * 365)
|
||||
sh = rets.mean() / rets.std() * ann if rets.std() > 0 else 0.0
|
||||
cum = rets.cumsum()
|
||||
dd = (cum - cum.cummax()).min() * 100
|
||||
wr = (trades > 0).mean() * 100 if len(trades) else 0.0
|
||||
print(f" {label:10} ret={rets.sum() * 100:+7.1f}% Sh={sh:5.2f} DD={dd:6.2f}% "
|
||||
f"n={len(trades):3d} WR={wr:4.1f}%")
|
||||
|
||||
|
||||
def book_reality_check(c: CerberoClient) -> None:
|
||||
"""Il test che separa edge vero da illusione: il BOOK e' dislocato o solo i print?"""
|
||||
print("\n== Book Deribit vs mark Hyperliquid (live) ==")
|
||||
for coin, inst in PAIRS:
|
||||
try:
|
||||
ob = c._post("/mcp-deribit/tools/get_orderbook", {"instrument_name": inst, "depth": 5})
|
||||
ht = c._post("/mcp-hyperliquid/tools/get_ticker", {"instrument": coin})
|
||||
bb, ba = ob["bids"][0][0], ob["asks"][0][0]
|
||||
mid, hm = (bb + ba) / 2, ht["mark_price"]
|
||||
print(f" {inst:22} book {bb}/{ba} Δbook-HL={100 * (mid / hm - 1):+.2f}% "
|
||||
f"depth5 bid={sum(b[1] for b in ob['bids']):.3g} ask={sum(a[1] for a in ob['asks']):.3g}")
|
||||
except Exception as e: # endpoint o strumento indisponibile: solo report
|
||||
print(f" {inst:22} ERR {e}")
|
||||
|
||||
|
||||
def run() -> None:
|
||||
c = CerberoClient()
|
||||
data = {coin: fetch(c, coin, inst) for coin, inst in PAIRS}
|
||||
for coin, j in data.items():
|
||||
s = np.log(j["d"] / j["h"]) * 100
|
||||
rho = (s - s.mean()).autocorr(1)
|
||||
hlife = -np.log(2) / np.log(rho) if 0 < rho < 1 else float("inf")
|
||||
flat = (j["d"].pct_change() == 0).mean() * 100
|
||||
print(f"\n== {coin} ({len(j)} barre 1h) spread mean={s.mean():+.2f}% std={s.std():.2f}% "
|
||||
f"half-life={hlife:.1f}h flatD={flat:.0f}%")
|
||||
convergence_table(j)
|
||||
for lag in (0, 1):
|
||||
r, t = backtest(j, lag=lag)
|
||||
report(r, t, f"FULL lag{lag}")
|
||||
roo, too = backtest(j[j.index >= SPLIT], lag=lag)
|
||||
report(roo, too, f"OOS lag{lag}")
|
||||
book_reality_check(c)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -96,8 +96,14 @@ def lossguard_section() -> str:
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
# Epoca v1.1.26 (deploy 2026-06-11 ~21:40 UTC): gate TP_PHANTOM attivo. I close
|
||||
# precedenti includono il churn da TP fantasma dell'11-06 17:32-17:58 (~24 giri,
|
||||
# win-rate inquinato) -> le accuratezze "pulite" si leggono da qui in poi.
|
||||
EPOCH_V1126 = "2026-06-11T21:40:00"
|
||||
|
||||
|
||||
def collect():
|
||||
closed = [] # (sleeve, reason, net_return, pnl, win)
|
||||
closed = [] # (sleeve, reason, net_return, pnl, win, ts)
|
||||
open_pos = [] # dict per posizione aperta
|
||||
realized = 0.0
|
||||
for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))):
|
||||
@@ -119,7 +125,7 @@ def collect():
|
||||
# resta il sim diagnostico: sui TP fantasma da spike testnet diceva
|
||||
# 26/0 mentre il reale era 11/15). Fallback nr>0 per eventi storici.
|
||||
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl,
|
||||
bool(ev.get("win", nr > 0))))
|
||||
bool(ev.get("win", nr > 0)), ev.get("ts", "")))
|
||||
if "positions" in st or "weights" in st:
|
||||
continue # multi-asset (TR01/ROT02/TSM01): sezione dedicata
|
||||
if st.get("in_position"):
|
||||
@@ -187,7 +193,7 @@ def build_report() -> str:
|
||||
|
||||
# breakdown per motivo
|
||||
by_reason = defaultdict(lambda: [0, 0, 0.0]) # reason -> [win, loss, pnl]
|
||||
for _, reason, _, pnl, win in closed:
|
||||
for _, reason, _, pnl, win, _ in closed:
|
||||
r = by_reason[reason]
|
||||
r[0 if win else 1] += 1
|
||||
r[2] += pnl
|
||||
@@ -206,8 +212,12 @@ def build_report() -> str:
|
||||
if eq is not None:
|
||||
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
|
||||
|
||||
# 1) CHIUSI
|
||||
# 1) CHIUSI — totale storico + epoca corrente (post gate TP_PHANTOM): i
|
||||
# numeri pre-v1.1.26 includono il churn fantasma e non misurano la strategia
|
||||
cur = [c for c in closed if c[5] >= EPOCH_V1126]
|
||||
cpos = sum(1 for c in cur if c[4])
|
||||
L.append(f"\n✅ <b>CHIUSI</b>: {pos} positivi / {neg} negativi (netto fee)")
|
||||
L.append(f" epoca v1.1.26+ (TP_PHANTOM attivo): {cpos}/{len(cur) - cpos}")
|
||||
rows = [f"{'motivo':<12}{'✅':>3}{'❌':>4}{'PnL€':>9}"]
|
||||
for reason, (w, l, pnl) in sorted(by_reason.items(), key=lambda x: x[1][2]):
|
||||
rows.append(f"{reason:<12}{w:>3}{l:>4}{pnl:>+9.2f}")
|
||||
|
||||
@@ -53,6 +53,28 @@ def real_books(exclude_worker: str | None = None) -> tuple[dict[str, float], dic
|
||||
return books, orphans
|
||||
|
||||
|
||||
def expected_resting() -> list[dict]:
|
||||
"""Ordini resting ATTESI sul book dai libri dei worker single-leg in posizione
|
||||
reale: TP limit reduce-only (real_tp_order_id) e disaster-SL stop_market
|
||||
(real_dsl_order_id). I pairs non hanno resting. Ogni voce:
|
||||
{worker, instrument, order_id, kind: 'tp'|'dsl'}."""
|
||||
out: list[dict] = []
|
||||
for sp in sorted(PAPER.glob("*/status.json")):
|
||||
wid = sp.parent.name
|
||||
try:
|
||||
st = json.loads(sp.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if not st.get("real_in_position") or st.get("real_amount_a"):
|
||||
continue
|
||||
inst = _inst(wid.split("__")[1])
|
||||
for key, kind in (("real_tp_order_id", "tp"), ("real_dsl_order_id", "dsl")):
|
||||
oid = st.get(key)
|
||||
if oid:
|
||||
out.append(dict(worker=wid, instrument=inst, order_id=str(oid), kind=kind))
|
||||
return out
|
||||
|
||||
|
||||
def account_net(client) -> dict[str, float]:
|
||||
"""Posizioni reali per strumento dal conto (size USD / mark -> coin, firmato)."""
|
||||
out: dict[str, float] = {}
|
||||
|
||||
@@ -80,6 +80,16 @@ class CerberoClient:
|
||||
def get_positions(self, currency: str = "ETH") -> list[dict]:
|
||||
return self._post("/mcp-deribit/tools/get_positions", {"currency": currency})
|
||||
|
||||
def get_open_orders(self, currency: str = "USDC", type: str = "all") -> list[dict]:
|
||||
"""Ordini APERTI sul conto (limit resting + trigger non scattati). Ogni voce:
|
||||
{order_id, instrument, direction, order_type, order_state, amount,
|
||||
filled_amount, price, trigger_price, reduce_only, label}. NB Deribit puo'
|
||||
omettere i trigger untriggered da type='all' -> per i bracket interrogare
|
||||
anche type='trigger_all' e fare merge per order_id."""
|
||||
out = self._post("/mcp-deribit/tools/get_open_orders",
|
||||
{"currency": currency, "type": type})
|
||||
return out if isinstance(out, list) else out.get("orders", [])
|
||||
|
||||
# --- Trading ---
|
||||
|
||||
def place_order(
|
||||
|
||||
+64
-1
@@ -183,6 +183,20 @@ class ExecutionClient:
|
||||
# (poll-loop fermo = posizione reale senza valutazione exit). None = disattivo.
|
||||
# Configurato dal runner da overrides.execution.disaster_sl_pct.
|
||||
disaster_sl_pct: float | None = None
|
||||
# Circuit-breaker venue-lock (2026-06-12): durante il lock admin del testnet
|
||||
# (rollback conto, ~09:47) ogni place_order rispondeva 'locked_by_admin' ma i
|
||||
# worker continuavano a tentare APERTURE (leg-fail pairs + unwind + fee
|
||||
# sprecate sui leg parziali). Dopo lock_trip errori 'locked' consecutivi le
|
||||
# aperture sono SOSPESE (Fill failed senza chiamata API -> i worker seguono il
|
||||
# path REAL_OPEN_FAIL/sim_fallback gia' esistente); le CHIUSURE si tentano
|
||||
# SEMPRE (path gia' sicuro: partial/orphan/netting). Riarmo: passato
|
||||
# lock_cooldown_s la prossima apertura fa da probe — se passa il breaker si
|
||||
# resetta (alert di rientro), se e' ancora locked riscatta subito. Stato in
|
||||
# memoria: al restart il primo open rifiutato lo ri-arma.
|
||||
lock_trip: int = 3
|
||||
lock_cooldown_s: float = 900.0
|
||||
_lock_streak: int = field(default=0, init=False, repr=False)
|
||||
_lock_until: float = field(default=0.0, init=False, repr=False)
|
||||
# NB leva: su Deribit la leva per-strumento NON e' impostabile (private/set_leverage
|
||||
# risponde 400 Bad Request — verificato 2026-06-03 nei log Cerbero; il set_leverage
|
||||
# di Cerbero fallisce sempre, soppresso). Il campo "leverage: 50" in get_positions
|
||||
@@ -214,6 +228,46 @@ class ExecutionClient:
|
||||
pass
|
||||
return None
|
||||
|
||||
# --- circuit-breaker venue-lock ---
|
||||
|
||||
def _notify_safe(self, event: str, data: dict):
|
||||
try:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event(event, data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def lock_blocked(self) -> bool:
|
||||
"""True se le APERTURE sono sospese (breaker scattato e cooldown attivo)."""
|
||||
return self._lock_streak >= self.lock_trip and time.monotonic() < self._lock_until
|
||||
|
||||
def _lock_track(self, error: str):
|
||||
"""Conta gli errori 'locked' consecutivi; al trip sospende le aperture.
|
||||
Ogni nuovo 'locked' (anche dalle chiusure) rinfresca il cooldown: finche'
|
||||
il venue resta bloccato le aperture non riprendono. Gli errori di altra
|
||||
natura NON toccano lo streak (un transitorio di rete non deve ne'
|
||||
armare ne' disarmare il breaker)."""
|
||||
if "locked" not in (error or "").lower():
|
||||
return
|
||||
self._lock_streak += 1
|
||||
self._lock_until = time.monotonic() + self.lock_cooldown_s
|
||||
if self._lock_streak == self.lock_trip:
|
||||
print(f"[exec] VENUE_LOCK: {self._lock_streak} reject 'locked' consecutivi "
|
||||
f"-> aperture sospese {self.lock_cooldown_s / 60:.0f}m (probe al termine)")
|
||||
self._notify_safe("VENUE_LOCK", {
|
||||
"reject_consecutivi": self._lock_streak,
|
||||
"cooldown_min": round(self.lock_cooldown_s / 60),
|
||||
"nota": "conto locked (admin/rollback testnet): aperture reali sospese, "
|
||||
"chiusure sempre tentate, sim prosegue"})
|
||||
|
||||
def _lock_reset(self):
|
||||
"""Ordine accettato dal venue: se il breaker era scattato, dichiara il rientro."""
|
||||
if self._lock_streak >= self.lock_trip:
|
||||
self._notify_safe("VENUE_LOCK", {"status": "RIENTRATO",
|
||||
"dopo_reject": self._lock_streak})
|
||||
self._lock_streak = 0
|
||||
self._lock_until = 0.0
|
||||
|
||||
# --- API ---
|
||||
|
||||
def _mark_price(self, instrument: str) -> float | None:
|
||||
@@ -246,9 +300,11 @@ class ExecutionClient:
|
||||
resp = self.client.place_order(instrument, side, amount, order_type=order_type,
|
||||
price=price, label=label, reduce_only=reduce_only)
|
||||
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
|
||||
self._lock_track(str(resp.get("error", "")) if isinstance(resp, dict) else "")
|
||||
return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0,
|
||||
None, "error", False, raw=resp if isinstance(resp, dict) else {},
|
||||
notes=f"place_order error: {resp}")
|
||||
self._lock_reset()
|
||||
|
||||
order = resp.get("order", resp) or {}
|
||||
trades = resp.get("trades", []) or []
|
||||
@@ -299,7 +355,14 @@ class ExecutionClient:
|
||||
|
||||
def open(self, instrument: str, side: str, notional_usd: float,
|
||||
label: str | None = None) -> Fill:
|
||||
"""Apre la quota del worker (market, NON reduce_only)."""
|
||||
"""Apre la quota del worker (market, NON reduce_only). Con breaker
|
||||
venue-lock attivo NON tocca l'API: Fill failed -> il chiamante segue il
|
||||
path REAL_OPEN_FAIL/sim_fallback (per i pairs: entrambe le gambe
|
||||
rifiutate localmente, nessun leg parziale da unwindare)."""
|
||||
if self.lock_blocked():
|
||||
return Fill(instrument, side, notional_usd, 0.0, None, 0.0, 0.0,
|
||||
None, "error", False,
|
||||
notes="venue_lock_breaker: aperture sospese (conto locked)")
|
||||
amount = self.amount_for(instrument, notional_usd)
|
||||
return self._submit(instrument, side, amount, notional_usd,
|
||||
reduce_only=False, label=label)
|
||||
|
||||
@@ -71,6 +71,12 @@ class StrategyWorker:
|
||||
self.real_dsl_order_id = "" # STOP_MARKET disaster bracket on-book (persistito)
|
||||
self.real_trades = 0
|
||||
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
|
||||
# Quote residue dei close FALLITI/cappati (2026-06-12, parità coi pairs):
|
||||
# prima il REAL_CLOSE_PARTIAL single-leg NON registrava l'orfano e il
|
||||
# reconciler vedeva drift NON spiegato (caso MR07 0.102 ETH nel lock
|
||||
# testnet). Stessa semantica di PairsWorker.orphan_legs: posizioni che il
|
||||
# conto ha ancora ma i libri hanno chiuso; le legge books.real_books.
|
||||
self.orphan_legs: list[dict] = []
|
||||
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
|
||||
self._tp_phantom_notified = False # alert Telegram una tantum per processo
|
||||
self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom
|
||||
@@ -144,6 +150,7 @@ class StrategyWorker:
|
||||
self.real_dsl_order_id = state.get("real_dsl_order_id", "")
|
||||
self.real_trades = state.get("real_trades", 0)
|
||||
self.real_first_notified = state.get("real_first_notified", False)
|
||||
self.orphan_legs = state.get("orphan_legs", [])
|
||||
|
||||
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||
"total_trades": self.total_trades,
|
||||
@@ -178,6 +185,7 @@ class StrategyWorker:
|
||||
"real_dsl_order_id": self.real_dsl_order_id,
|
||||
"real_trades": self.real_trades,
|
||||
"real_first_notified": self.real_first_notified,
|
||||
"orphan_legs": self.orphan_legs,
|
||||
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
with open(self.status_path, "w") as f:
|
||||
@@ -412,10 +420,18 @@ class StrategyWorker:
|
||||
self._log("NET_CLOSE", {"note": fill.notes})
|
||||
self._notify("NET_CLOSE", {"note": fill.notes})
|
||||
if fill and market_amt < remainder - step / 2:
|
||||
residual = round(remainder - market_amt, 6)
|
||||
# registra l'orfano (come PairsWorker): il conto ha ancora questa quota
|
||||
# ma il libro chiude -> il reconciler la conta come drift SPIEGATO
|
||||
self.orphan_legs.append({
|
||||
"instrument": self.exec_instrument, "entry_side": self.real_side,
|
||||
"amount": residual,
|
||||
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason})
|
||||
data = {"requested": remainder, "filled": market_amt,
|
||||
"residuo_orfano": round(remainder - market_amt, 6),
|
||||
"residuo_orfano": residual,
|
||||
"note": ("close non completato (netting negato/leg fallito): "
|
||||
"quota residua NON chiusa — verificare col reconciler")}
|
||||
"quota residua registrata in orphan_legs — "
|
||||
"verificare col reconciler")}
|
||||
self._log("REAL_CLOSE_PARTIAL", data)
|
||||
self._notify("REAL_CLOSE_PARTIAL", data)
|
||||
|
||||
@@ -574,6 +590,9 @@ class StrategyWorker:
|
||||
self.tp = 0.0
|
||||
self.sl = 0.0
|
||||
self.max_bars = 0
|
||||
# persisti il booking della chiusura SUBITO (non solo al save del tick):
|
||||
# un crash qui perderebbe capital/orphan_legs gia' contabilizzati
|
||||
self._save_state()
|
||||
|
||||
def tick(self, df: pd.DataFrame, df_1h: pd.DataFrame | None = None):
|
||||
"""Chiamato ad ogni poll con DataFrame OHLCV aggiornato.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Guard FEED_BOOK_GAP (2026-06-12): il feed candele e il book d'esecuzione possono
|
||||
divergere (MR02_BTC: fill reale a -443 bps dal sim; wick TP_PHANTOM il caso opposto).
|
||||
Alert per episodio con isteresi a soglia/2, fail-open su errori di rete."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.portfolio import runner
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, marks):
|
||||
self._marks = marks
|
||||
self.calls = 0
|
||||
|
||||
def get_ticker_batch(self, instruments):
|
||||
self.calls += 1
|
||||
return {"tickers": [{"instrument_name": i, "mark_price": self._marks[i]}
|
||||
for i in instruments if i in self._marks]}
|
||||
|
||||
|
||||
class _Boom:
|
||||
def get_ticker_batch(self, instruments):
|
||||
raise RuntimeError("rete giu'")
|
||||
|
||||
|
||||
INSTR = {"BTC": "BTC_USDC-PERPETUAL"}
|
||||
|
||||
|
||||
def _feed(close):
|
||||
return {"BTC": pd.DataFrame({"close": [100.0, close]})}
|
||||
|
||||
|
||||
def _events(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
|
||||
lambda ev, data: sent.append((ev, data)))
|
||||
return sent
|
||||
|
||||
|
||||
def test_gap_alerts_once_per_episode(monkeypatch):
|
||||
sent = _events(monkeypatch)
|
||||
cl = _Client({"BTC_USDC-PERPETUAL": 60481.0})
|
||||
alerted = set()
|
||||
# feed a 63285 vs mark 60481 = il caso MR02 (~464 bps) -> alert, una volta sola
|
||||
for _ in range(3):
|
||||
runner._check_feed_book_gap(cl, _feed(63285.5), INSTR, 150.0, alerted)
|
||||
assert len(sent) == 1 and sent[0][0] == "FEED_BOOK_GAP"
|
||||
assert sent[0][1]["asset"] == "BTC" and sent[0][1]["gap_bps"] > 400
|
||||
|
||||
|
||||
def test_recovery_with_hysteresis(monkeypatch):
|
||||
sent = _events(monkeypatch)
|
||||
cl = _Client({"BTC_USDC-PERPETUAL": 60000.0})
|
||||
alerted = {"BTC"}
|
||||
# gap fra soglia/2 e soglia: NESSUN recovery (isteresi)
|
||||
runner._check_feed_book_gap(cl, _feed(60000 * 1.0090), INSTR, 150.0, alerted)
|
||||
assert sent == [] and "BTC" in alerted
|
||||
# gap sotto soglia/2 -> RIENTRATO
|
||||
runner._check_feed_book_gap(cl, _feed(60010.0), INSTR, 150.0, alerted)
|
||||
assert len(sent) == 1 and sent[0][1].get("status") == "RIENTRATO"
|
||||
assert "BTC" not in alerted
|
||||
|
||||
|
||||
def test_fail_open_and_no_instruments(monkeypatch):
|
||||
sent = _events(monkeypatch)
|
||||
runner._check_feed_book_gap(_Boom(), _feed(63285.5), INSTR, 150.0, set())
|
||||
cl = _Client({"BTC_USDC-PERPETUAL": 60481.0})
|
||||
runner._check_feed_book_gap(cl, {}, INSTR, 150.0, set()) # nessun feed
|
||||
assert sent == [] and cl.calls == 0
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Reconcile degli ordini RESTING (2026-06-12): TP/DSL attesi dai libri vs ordini
|
||||
in book + fill non bookati (caso MR02_BTC: TP resting fillato di notte e disaster-SL
|
||||
sparito, scoperti solo al close sim ore dopo). Nessuna rete: client stubbato,
|
||||
PAPER puntato su tmp_path."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.live import books
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, open_orders=None, trades=None):
|
||||
self._oo = open_orders or []
|
||||
self._tr = trades or []
|
||||
|
||||
def get_open_orders(self, currency="USDC", type="all"):
|
||||
# merge 'all'+'trigger_all' nel chiamante: qui si ritorna tutto due volte
|
||||
return self._oo
|
||||
|
||||
def get_trade_history(self, limit=100, instrument_name=None):
|
||||
return [t for t in self._tr
|
||||
if not instrument_name or t["instrument"] == instrument_name]
|
||||
|
||||
|
||||
def _mk_worker(root: Path, wid: str, st: dict):
|
||||
d = root / wid
|
||||
d.mkdir(parents=True)
|
||||
(d / "status.json").write_text(json.dumps(st))
|
||||
|
||||
|
||||
def _setup(tmp_path, monkeypatch, statuses: dict):
|
||||
root = tmp_path / "portfolio_paper"
|
||||
for wid, st in statuses.items():
|
||||
_mk_worker(root, wid, st)
|
||||
monkeypatch.setattr(books, "PAPER", root)
|
||||
import importlib
|
||||
ra = importlib.import_module("scripts.analysis.reconcile_account")
|
||||
monkeypatch.setattr(ra, "PAPER", root)
|
||||
return ra
|
||||
|
||||
|
||||
IN_POS = {"real_in_position": True, "real_tp_order_id": "TP-1",
|
||||
"real_dsl_order_id": "DSL-1"}
|
||||
|
||||
|
||||
def test_expected_resting_reads_single_leg(tmp_path, monkeypatch):
|
||||
_setup(tmp_path, monkeypatch, {
|
||||
"MR01_bollinger_fade__ETH__1h": IN_POS,
|
||||
# pairs e worker flat: NESSUN resting atteso
|
||||
"PR01_pairs_reversion__ETH_BTC__1h": {"real_in_position": True,
|
||||
"real_amount_a": 0.1},
|
||||
"MR02_donchian_fade__BTC__1h": {"real_in_position": False,
|
||||
"real_tp_order_id": "TP-9"},
|
||||
})
|
||||
exp = books.expected_resting()
|
||||
assert {(e["order_id"], e["kind"]) for e in exp} == {("TP-1", "tp"), ("DSL-1", "dsl")}
|
||||
assert all(e["instrument"] == "ETH_USDC-PERPETUAL" for e in exp)
|
||||
|
||||
|
||||
def test_resting_ok_when_on_book(tmp_path, monkeypatch):
|
||||
ra = _setup(tmp_path, monkeypatch, {"MR01_bollinger_fade__ETH__1h": IN_POS})
|
||||
cl = _Client(open_orders=[{"order_id": "TP-1", "label": "MR01_bollinger_fade__ETH__1h"},
|
||||
{"order_id": "DSL-1", "label": "MR01_bollinger_fade__ETH__1h"}])
|
||||
rows = ra.compute_resting_drift(cl)
|
||||
assert [r["status"] for r in rows] == ["OK", "OK"]
|
||||
|
||||
|
||||
def test_resting_filled_unbooked_vs_missing(tmp_path, monkeypatch):
|
||||
# TP sparito dal book MA con fill nel trade history -> FILLED_UNBOOKED (caso MR02);
|
||||
# DSL sparito senza fill (trigger genera order_id nuovo) -> MISSING
|
||||
ra = _setup(tmp_path, monkeypatch, {"MR01_bollinger_fade__ETH__1h": IN_POS})
|
||||
cl = _Client(open_orders=[],
|
||||
trades=[{"instrument": "ETH_USDC-PERPETUAL", "order_id": "TP-1",
|
||||
"amount": 0.103, "price": 1640.6}])
|
||||
by_kind = {r["kind"]: r for r in ra.compute_resting_drift(cl)}
|
||||
assert by_kind["tp"]["status"] == "FILLED_UNBOOKED"
|
||||
assert abs(by_kind["tp"]["filled"] - 0.103) < 1e-9
|
||||
assert by_kind["dsl"]["status"] == "MISSING"
|
||||
|
||||
|
||||
def test_resting_stale_order_from_flat_worker(tmp_path, monkeypatch):
|
||||
# ordine in book con label di un NOSTRO worker flat -> STALE (fillerebbe a sorpresa);
|
||||
# ordini di altri bot (label sconosciuta) ignorati
|
||||
ra = _setup(tmp_path, monkeypatch, {
|
||||
"MR01_bollinger_fade__ETH__1h": {"real_in_position": False}})
|
||||
cl = _Client(open_orders=[
|
||||
{"order_id": "OLD-7", "label": "MR01_bollinger_fade__ETH__1h",
|
||||
"instrument": "ETH_USDC-PERPETUAL", "order_type": "limit"},
|
||||
{"order_id": "X-1", "label": "altro_bot", "instrument": "ETH_USDC-PERPETUAL"},
|
||||
])
|
||||
rows = ra.compute_resting_drift(cl)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["status"] == "STALE" and rows[0]["order_id"] == "OLD-7"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Orfani single-leg (2026-06-12): un close fallito/cappato registra la quota
|
||||
residua in orphan_legs (parita' coi pairs) cosi' il reconciler la conta come
|
||||
drift SPIEGATO — prima il REAL_CLOSE_PARTIAL di MR07 (0.102 ETH nel lock testnet)
|
||||
lasciava drift non spiegato. Nessuna rete: executor finto."""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.live.execution import Fill
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
from src.strategies.base import Signal
|
||||
from src.live import books
|
||||
|
||||
|
||||
class FailingCloseExec:
|
||||
"""Apre ok, chiude con fill ZERO (venue locked / netting negato)."""
|
||||
verify_polls = 1
|
||||
verify_sleep = 0.0
|
||||
disaster_sl_pct = None
|
||||
|
||||
def open(self, instrument, side, notional, label=None):
|
||||
amt = round(notional / 100.0, 6)
|
||||
return Fill(instrument, side, notional, amt, 100.0, 0.0, 0.05,
|
||||
"oid-open", "filled", True, filled_amount=amt)
|
||||
|
||||
def place_tp_limit(self, *a, **k):
|
||||
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
|
||||
|
||||
def cancel_order(self, oid):
|
||||
return {"state": "cancelled"}
|
||||
|
||||
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, None, 0.0, 0.0,
|
||||
None, "error", False, notes="place_order error: locked_by_admin",
|
||||
filled_amount=0.0)
|
||||
|
||||
|
||||
def _worker(tmp_path):
|
||||
return StrategyWorker(
|
||||
strategy=SimpleNamespace(name="MR07_return_reversal", fee_rt=0.001),
|
||||
asset="ETH", tf="1h", capital=100.0, position_size=0.5, leverage=2.0,
|
||||
data_dir=tmp_path, executor=FailingCloseExec(),
|
||||
exec_instrument="ETH_USDC-PERPETUAL")
|
||||
|
||||
|
||||
def test_failed_close_registers_orphan(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
|
||||
w = _worker(tmp_path)
|
||||
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
|
||||
metadata={"max_bars": 12}), 100.0)
|
||||
amt = w.real_amount
|
||||
assert w.real_in_position and amt > 0
|
||||
w._close_position(105.0, "time_limit")
|
||||
assert not w.real_in_position
|
||||
assert len(w.orphan_legs) == 1
|
||||
o = w.orphan_legs[0]
|
||||
assert o["instrument"] == "ETH_USDC-PERPETUAL"
|
||||
assert o["entry_side"] == "buy" and abs(o["amount"] - amt) < 1e-9
|
||||
|
||||
# persistito nello status (resume-safe) e visto da books.real_books
|
||||
st = json.loads((tmp_path / w.worker_id / "status.json").read_text())
|
||||
assert st["orphan_legs"] == w.orphan_legs
|
||||
monkeypatch.setattr(books, "PAPER", tmp_path)
|
||||
_, orphans = books.real_books()
|
||||
assert abs(orphans["ETH_USDC-PERPETUAL"] - amt) < 1e-9 # buy = firmato +
|
||||
|
||||
|
||||
def test_clean_close_no_orphan(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("src.live.strategy_worker.notify_event", lambda *a, **k: None)
|
||||
w = _worker(tmp_path)
|
||||
w.executor.close_amount = lambda instrument, side, amount, label=None: Fill(
|
||||
instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
|
||||
"filled", True, filled_amount=amount)
|
||||
w._open_position(Signal(idx=0, direction=1, entry_price=100.0,
|
||||
metadata={"max_bars": 12}), 100.0)
|
||||
w._close_position(105.0, "time_limit")
|
||||
assert w.orphan_legs == []
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Circuit-breaker venue-lock (2026-06-12): dopo lock_trip reject 'locked'
|
||||
consecutivi le APERTURE sono sospese senza toccare l'API (i worker seguono il
|
||||
path REAL_OPEN_FAIL/sim_fallback); le chiusure si tentano sempre; il probe a
|
||||
fine cooldown riarma o resetta. Nessuna rete: CerberoClient stubbato."""
|
||||
import time
|
||||
|
||||
from src.live.execution import ExecutionClient
|
||||
|
||||
|
||||
class _Venue:
|
||||
"""Stub CerberoClient: locked finche' .locked=True, poi ordini ok."""
|
||||
def __init__(self):
|
||||
self.locked = True
|
||||
self.place_calls = 0
|
||||
|
||||
def place_order(self, instrument, side, amount, order_type="market",
|
||||
price=None, label=None, reduce_only=False):
|
||||
self.place_calls += 1
|
||||
if self.locked:
|
||||
return {"state": "error", "error": "locked_by_admin"}
|
||||
return {"order": {"order_id": "o1", "order_state": "filled",
|
||||
"filled_amount": amount, "average_price": 100.0},
|
||||
"trades": [{"amount": amount, "price": 100.0, "fee": 0.0}]}
|
||||
|
||||
def get_ticker(self, instrument):
|
||||
return {"mark_price": 100.0}
|
||||
|
||||
def get_trade_history(self, limit=50, instrument_name=None):
|
||||
return []
|
||||
|
||||
|
||||
def _ec(monkeypatch, sent):
|
||||
monkeypatch.setattr("src.live.telegram_notifier.notify_event",
|
||||
lambda ev, data: sent.append((ev, data)))
|
||||
venue = _Venue()
|
||||
ec = ExecutionClient(client=venue)
|
||||
return ec, venue
|
||||
|
||||
|
||||
INST = "ETH_USDC-PERPETUAL"
|
||||
|
||||
|
||||
def test_breaker_trips_and_blocks_opens(monkeypatch):
|
||||
sent = []
|
||||
ec, venue = _ec(monkeypatch, sent)
|
||||
for _ in range(ec.lock_trip):
|
||||
f = ec.open(INST, "buy", 50.0)
|
||||
assert not f.verified
|
||||
assert venue.place_calls == ec.lock_trip and ec.lock_blocked()
|
||||
assert [e for e, _ in sent] == ["VENUE_LOCK"] # un alert al trip
|
||||
# aperture sospese: NESSUNA chiamata API, nota dedicata
|
||||
f = ec.open(INST, "buy", 50.0)
|
||||
assert venue.place_calls == ec.lock_trip
|
||||
assert "venue_lock_breaker" in f.notes
|
||||
assert sent == sent[:1] # niente spam
|
||||
|
||||
|
||||
def test_closes_still_attempted_and_refresh_cooldown(monkeypatch):
|
||||
sent = []
|
||||
ec, venue = _ec(monkeypatch, sent)
|
||||
for _ in range(ec.lock_trip):
|
||||
ec.open(INST, "buy", 50.0)
|
||||
ec._net_close_allowance = lambda *a, **k: 0.0 # niente fallback netting
|
||||
before = ec._lock_until
|
||||
time.sleep(0.01)
|
||||
f = ec.close_amount(INST, "buy", 0.5, label="w1") # la chiusura PASSA dall'API
|
||||
assert venue.place_calls == ec.lock_trip + 1
|
||||
assert not f.verified
|
||||
assert ec._lock_until > before # locked dal close -> cooldown esteso
|
||||
|
||||
|
||||
def test_probe_rearms_or_resets(monkeypatch):
|
||||
sent = []
|
||||
ec, venue = _ec(monkeypatch, sent)
|
||||
for _ in range(ec.lock_trip):
|
||||
ec.open(INST, "buy", 50.0)
|
||||
# cooldown scaduto + venue ancora locked -> probe fallisce e riscatta
|
||||
ec._lock_until = time.monotonic() - 1
|
||||
ec.open(INST, "buy", 50.0)
|
||||
assert venue.place_calls == ec.lock_trip + 1 and ec.lock_blocked()
|
||||
# venue sbloccato -> il probe passa, breaker resettato, alert di rientro
|
||||
ec._lock_until = time.monotonic() - 1
|
||||
venue.locked = False
|
||||
f = ec.open(INST, "buy", 50.0)
|
||||
assert f.verified and not ec.lock_blocked() and ec._lock_streak == 0
|
||||
assert sent[-1][0] == "VENUE_LOCK" and sent[-1][1].get("status") == "RIENTRATO"
|
||||
Reference in New Issue
Block a user