5 Commits

Author SHA1 Message Date
Adriano Dal Pastro 515193a203 docs(research): XEX — discordanze Deribit testnet vs Hyperliquid
Spread D/H mean-reverting (half-life 3-7h); su BTC/ETH inverse il BOOK stesso
e' dislocato 1-2% (reale, eseguibile), su DOGE/SOL e' stale-print illusion
(book allineato a HL, 87%/35% barre flat). Edge testnet-only: non deployato,
record + telemetria. Vedi diario.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:29:11 +00:00
Adriano Dal Pastro 612f2bfced feat(live): reconcile resting + orphan single-leg + circuit-breaker venue-lock + FEED_BOOK_GAP
Codice della tornata v1.1.27/28 (gia' in produzione, mai committato):
- reconcile_account: estensione ordini RESTING (FILLED_UNBOOKED/MISSING/STALE,
  caso MR02_BTC: TP fillato di notte scoperto ore dopo) + expected_resting in books
- strategy_worker: orphan_legs su REAL_CLOSE_PARTIAL anche single-leg, persistito
- execution: circuit-breaker su venue-lock admin (stop ordini dopo errori ripetuti)
- runner/hourly_report: alert FEED_BOOK_GAP + timestamp closed trades
- cerbero_client: get_open_orders (merge all + trigger_all)
Test: 12 nuovi, suite completa 126 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:29:02 +00:00
Adriano Dal Pastro a2d581691a release: v1.1.29 2026-06-12 20:23:51 +00:00
Adriano Dal Pastro 5fe53841e9 feat(live): leva 2x -> 3x su PORT06 (scelta utente, frontiera ACCEL50)
PAIRS position_size_family 0.20 -> 0.13 per conservare l'esposizione validata
~0.40 (gate 2026-06-07): la leva accelera le famiglie con stop, non i no-stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:23:43 +00:00
Adriano Dal Pastro e0257c6c88 docs(research): ACCEL50 — frontiera di leva PORT06 + probe fade 15m verso i 50 EUR/giorno
Leva 2->3/4 dimezza i tempi (OOS CAGR 111->206/343%, DD full 3.5->5.2/6.9%).
Fade 15m passa il probe (6/6 sleeve OOS+, fee 2x OK, BTC 15m > 1h a meta' DD).
Pairs nuove e PAXG bocciati: stale-print illusion (gambe alt 88-98% barre flat).

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