chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita

Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 15:16:03 +00:00
parent 8401a280b9
commit 14522262e6
383 changed files with 1971 additions and 779 deletions
View File
-44
View File
@@ -1,44 +0,0 @@
"""Helper condivisi sulle barre OHLCV live (lezione EXIT-16, 2026-06-05).
La riga -1 di un df di candele e' la candela IN FORMAZIONE finche' non e'
trascorsa la sua durata: valutare segnali/exit-confirm su quella riga
reintroduce la wick-sensitivity che EXIT-16 elimina (audit live: 2 stop su 3
del crash ETH erano wick-stop sulla barra in corso). Questa e' l'UNICA
implementazione della detection — prima era copiata in 4 punti
(strategy_worker, basket_trend_worker, pairs_worker, runner._check_stale_feed)
con una variante hardcoded a 1h: una correzione applicata a una copia e
dimenticata nelle altre reintrodurrebbe il bug in silenzio.
"""
from __future__ import annotations
import time
import numpy as np
def bar_ms_of(ts_ms, window: int = 50) -> int:
"""Durata stimata della barra: mediana dei diff degli ultimi `window`
timestamp (ms). 0 se la serie e' troppo corta per stimarla."""
ts = np.asarray(ts_ms, dtype="int64")
if len(ts) < 2:
return 0
return int(np.median(np.diff(ts[-window:])))
def last_bar_is_forming(ts_ms, now_ms: int | None = None) -> bool:
"""True se la riga -1 e' la candela IN CORSO (now < ts[-1] + durata barra)."""
ts = np.asarray(ts_ms, dtype="int64")
if len(ts) == 0:
return False
bar = bar_ms_of(ts)
if not bar:
return False
if now_ms is None:
now_ms = int(time.time() * 1000)
return now_ms < int(ts[-1]) + bar
def last_settled_idx(ts_ms, now_ms: int | None = None) -> int:
"""Indice dell'ultima barra COMPLETATA: -1 se la riga -1 e' chiusa,
-2 se e' in formazione."""
return -2 if last_bar_is_forming(ts_ms, now_ms) else -1
-106
View File
@@ -1,106 +0,0 @@
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
Replica live di honest_improve2._tr_basket_daily."""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
def _ema(x, n):
return pd.Series(x).ewm(span=n, adjust=False).mean().values
class BasketTrendWorker:
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.tf = tf
self.initial_capital = capital
self.capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.positions = {a: 0.0 for a in self.universe}
self.last_bar_ts = {a: 0 for a in self.universe}
self.in_position = False
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.positions = {**self.positions, **s.get("positions", {})}
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
self.in_position = any(v > 0 for v in self.positions.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "positions": self.positions,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
now_ms = int(time.time() * 1000)
rets = []
for a in self.universe:
df = data.get(a)
if df is None or len(df) < 111:
continue
# Scarta la barra 4h IN FORMAZIONE: crossover EMA e booking del return
# valutati SOLO su barre COMPLETE, come il reference
# honest_improve2._tr_basket_daily (lezione EXIT-16; evidenza live: flip
# SOL 0->1->0 in 59min nella stessa finestra 4h, -9.3% di glitch).
from src.live.bars import last_bar_is_forming
ts_arr = df["timestamp"].values.astype("int64")
c = df["close"].values
if last_bar_is_forming(ts_arr, now_ms):
c, ts_arr = c[:-1], ts_arr[:-1]
if len(c) < 110:
continue
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
target = 1.0 if ef > es else 0.0
bar_ts = int(ts_arr[-1])
prev = self.positions[a]
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
r = (c[-1] - c[-2]) / c[-2]
rets.append(self.position_size * self.leverage * r * prev)
if target != prev:
# fee = FEE_RT/2 * LEV come il reference (honest_improve2.py:150):
# il notional e' leveraged, la fee si paga sul notional
self.capital -= self.capital * self.position_size * self.leverage * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
self._log(a, prev, target, float(c[-1]))
self.positions[a] = target
self.last_bar_ts[a] = bar_ts
if rets:
# equal-weight 1/N sull'UNIVERSO come il reference (_tr_basket_daily):
# gli asset flat contribuiscono 0. mean(rets) mediava sui SOLI asset in
# posizione -> sovrappeso N/k a paniere parziale (con 1 long: 0.45 del
# capitale invece di 0.09) -> replay -44% vs reference +42%.
self.capital = max(self.capital * (1 + float(np.sum(rets)) / len(self.universe)), 10.0)
self.in_position = any(v > 0 for v in self.positions.values())
self._save()
def _log(self, asset, frm, to, price):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"asset": asset, "from": frm, "to": to,
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
@property
def status_summary(self):
longs = [a for a, v in self.positions.items() if v > 0]
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
-89
View File
@@ -1,89 +0,0 @@
"""Libri REALI per strumento dai status.json persistiti dei worker — fonte unica.
Usato da:
- scripts/analysis/reconcile_account.py (audit orario conto vs libri)
- ExecutionClient.close_amount (guard del netting: il residuo non-reduce-only
e' consentito solo fino al gap conto-vs-libri-altrui — senza questo guard un
close su stato stantio APRIREBBE una posizione nuda, code-review 2026-06-11)
Convenzione: amount firmato in base-coin (buy=+, sell=-), strumenti USDC lineari.
"""
from __future__ import annotations
import json
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
def _inst(asset: str) -> str:
return f"{asset}_USDC-PERPETUAL"
def real_books(exclude_worker: str | None = None) -> tuple[dict[str, float], dict[str, float]]:
"""(libri per strumento, orfani registrati per strumento), firmati.
exclude_worker: worker_id da ESCLUDERE dai libri (per il guard del netting:
il close del worker X deve portare il conto al netto degli ALTRI). Gli
orphan_legs NON vengono mai esclusi: sono posizioni che il conto ha ancora
indipendentemente da chi le ha originate."""
books: dict[str, float] = {}
orphans: dict[str, float] = {}
for sp in sorted(PAPER.glob("*/status.json")):
wid = sp.parent.name
try:
st = json.loads(sp.read_text())
except Exception:
continue
if st.get("real_in_position") and wid != exclude_worker:
parts = wid.split("__")
if st.get("real_amount_a"):
a, b = parts[1].split("_") # pairs: …__ETH_BTC__1h
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
(b, st["real_side_b"], st["real_amount_b"])):
sgn = 1 if side == "buy" else -1
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
elif st.get("real_amount"):
sgn = 1 if st.get("real_side") == "buy" else -1
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
for o in st.get("orphan_legs", []):
sgn = 1 if o.get("entry_side") == "buy" else -1
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
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] = {}
for p in client.get_positions(currency="USDC") or []:
inst = p.get("instrument")
size = float(p.get("size") or 0)
mark = float(p.get("mark_price") or 0)
if not inst or not size or not mark:
continue
amt = size / mark
out[inst] = amt if p.get("direction") == "long" else -amt
return out
-164
View File
@@ -1,164 +0,0 @@
"""Client HTTP per Cerbero MCP — Deribit.
Ambiente determinato dal TOKEN (vedi API_REFERENCE): TESTNET_TOKEN -> upstream
testnet, MAINNET_TOKEN -> upstream live. Default = testnet. Per il micro-test
mainnet basta esportare CERBERO_TOKEN (e opzionalmente CERBERO_BOT_TAG) nel `.env`
— NESSUNA modifica di codice. Vedi docs/specs/mainnet-microtest-plan.md."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any
import requests
BASE_URL = os.environ.get("CERBERO_BASE_URL", "https://cerbero-mcp.tielogic.xyz")
# Token TESTNET di default; override via env CERBERO_TOKEN per puntare a mainnet.
TESTNET_TOKEN = "_hm0FkyC67P9OXJTy7R9SE2lfhGz_Wa6i89KqH_uXrk"
TOKEN = os.environ.get("CERBERO_TOKEN", TESTNET_TOKEN)
BOT_TAG = os.environ.get("CERBERO_BOT_TAG", "pythagoras-paper")
TIMEOUT = 15
def is_mainnet() -> bool:
"""True se il token attivo NON è quello testnet di default (= upstream live)."""
return TOKEN != TESTNET_TOKEN
@dataclass
class CerberoClient:
base_url: str = BASE_URL
token: str = TOKEN
bot_tag: str = BOT_TAG
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"X-Bot-Tag": self.bot_tag,
"Content-Type": "application/json",
}
def _post(self, path: str, payload: dict | None = None) -> dict:
resp = requests.post(
f"{self.base_url}{path}",
headers=self._headers(),
json=payload or {},
timeout=TIMEOUT,
)
resp.raise_for_status()
return resp.json()
# --- Market data ---
def get_ticker(self, instrument: str = "ETH-PERPETUAL") -> dict:
return self._post("/mcp-deribit/tools/get_ticker", {"instrument": instrument})
def get_historical(self, instrument: str, start_date: str, end_date: str, resolution: str = "15") -> list[dict]:
data = self._post("/mcp-deribit/tools/get_historical", {
"instrument": instrument,
"start_date": start_date,
"end_date": end_date,
"resolution": resolution,
})
return data.get("candles", [])
def get_historical_v2(self, instrument: str, start_date: str, end_date: str,
interval: str = "1h", exchange: str = "deribit") -> list[dict]:
"""Endpoint unificato v2: /mcp/tools/get_historical (exchange deribit|hyperliquid).
Stesso shape candele del legacy: [{timestamp(ms), open, high, low, close, volume}]."""
data = self._post("/mcp/tools/get_historical", {
"exchange": exchange, "instrument": instrument,
"interval": interval, "start_date": start_date, "end_date": end_date,
})
return data.get("candles", [])
def get_instruments(self, currency: str, kind: str = "future",
exchange: str = "deribit", limit: int = 100) -> list[dict]:
"""Enumera gli strumenti reali (v2). Usato per risolvere il naming senza hardcoding."""
data = self._post("/mcp/tools/get_instruments", {
"exchange": exchange, "currency": currency, "kind": kind, "limit": limit,
})
return data.get("instruments", data if isinstance(data, list) else [])
def get_ticker_batch(self, instruments: list[str]) -> dict:
"""Prezzi correnti di N strumenti in una sola chiamata (v2, Deribit)."""
return self._post("/mcp-deribit/tools/get_ticker_batch", {"instruments": instruments})
# --- Account ---
def get_account_summary(self, currency: str = "USDC") -> dict:
return self._post("/mcp-deribit/tools/get_account_summary", {"currency": currency})
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(
self,
instrument: str,
side: str,
amount: float,
order_type: str = "market",
price: float | None = None,
leverage: int | None = None,
label: str | None = None,
reduce_only: bool = False,
) -> dict:
"""Piazza un ordine REALE su Deribit. `amount`: per i perp inverse
(BTC/ETH-PERPETUAL) e' in USD notional (step BTC $10, ETH $1); per i lineari
USDC (BTC_USDC/ETH_USDC-PERPETUAL) e' nel base-coin (step 0.0001/0.001).
`reduce_only=True` per chiudere solo la propria quota su uno strumento
condiviso (le posizioni si nettano per conto). Ritorna il `result` grezzo
Deribit: {"order": {...}, "trades": [{price, amount, fee, ...}]} → le fee
REALI sono in trades[]."""
payload: dict[str, Any] = {
"instrument_name": instrument,
"side": side,
"amount": amount,
"type": order_type,
}
if price is not None:
payload["price"] = price
if leverage is not None:
payload["leverage"] = leverage
if label:
payload["label"] = label
if reduce_only:
payload["reduce_only"] = True
return self._post("/mcp-deribit/tools/place_order", payload)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting (es. limit reduce-only al TP). Ritorna
{order_id, state}; state='error' se l'ordine non e' piu' open (gia'
fillato/cancellato) — il chiamante riconcilia via get_trade_history."""
return self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})
def close_position(self, instrument: str) -> dict:
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
def get_trade_history(self, limit: int = 100, instrument_name: str | None = None) -> list[dict]:
"""Trade ESEGUITI sul conto (fonte autorevole delle fee reali). Ogni voce:
{instrument, direction, price, amount, fee, timestamp, order_id}."""
payload: dict[str, Any] = {"limit": limit}
if instrument_name:
payload["instrument_name"] = instrument_name
out = self._post("/mcp-deribit/tools/get_trade_history", payload)
return out if isinstance(out, list) else out.get("trades", [])
def set_stop_loss(self, order_id: str, stop_price: float) -> dict:
return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price})
def set_take_profit(self, order_id: str, tp_price: float) -> dict:
return self._post("/mcp-deribit/tools/set_take_profit", {"order_id": order_id, "tp_price": tp_price})
-689
View File
@@ -1,689 +0,0 @@
"""Dashboard web PORT06 — stato live, PnL totale e per-strategia, grafici, trade.
Server self-contained (stdlib http.server, zero nuove dipendenze): legge i file
sotto data/ (equity.jsonl del ledger + status.json/trades.jsonl dei worker) e serve:
GET / -> single-page HTML (polling ogni 5s, grafico equity, barre PnL,
tabelle trade attivi in tempo reale + chiusi)
GET /api/state -> JSON con tutto lo stato calcolato
PnL per-strategia = somma dei `pnl` reali dai CLOSE (REAL-TRUTH); i trade attivi
mostrano il PnL NON realizzato live (mark corrente da Cerbero, best-effort cache 20s).
uv run python -m src.live.dashboard # porta 8787
uv run python -m src.live.dashboard --port 9000
# poi apri http://<host>:8787
"""
from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
STATS = PROJECT_ROOT / "data" / "portfolio_paper_stats"
LEDGER = PROJECT_ROOT / "data" / "portfolios" / "PORT06"
FAMILY = [("MR01", "FADE"), ("MR02", "FADE"), ("MR07", "FADE"), ("DIP01", "HONEST"),
("PR01", "PAIRS"), ("SH01", "SHAPE"), ("TR01", "HONEST"),
("ROT02", "HONEST"), ("TSM01", "TSM"), ("XS01", "XSEC")]
RETIRED_MIN = 30 # status.json non aggiornato da >30min = worker non piu' nel
# config attivo (es. le fade 1h sostituite dal 15m allo swap)
# Descrizioni concise (fonte: docs/report/strategie_attive.html / make_strategy_doc.py)
DESCRIPTIONS = {
"MR01": "Bollinger Fade — quando il close esce dalla banda ±2.5σ (SMA50) entra CONTRO il "
"movimento (short sopra/long sotto); TP alla media, SL 2·ATR, max 24 barre. Mean-reversion.",
"MR02": "Donchian Fade — fada la rottura degli estremi del canale a 20 barre (max/min recenti); "
"TP al centro del canale. Stessa tesi di MR01 con trigger diverso.",
"MR07": "Return Reversal — fada il rendimento di barra estremo (z-score ±3.5); exit in multipli "
"di ATR. La fade più selettiva (esposizione ~8% del tempo).",
"DIP01": "Dip Buy — compra il dip quando lo z del prezzo incrocia sotto 2.5; TP alla SMA50, "
"EXIT-16 sul SL, max 24 barre. Unico sleeve BTC con round-trip reali su testnet.",
"PR01": "Pairs Reversion — market-neutral: quando |z| del log-ratio fra due asset ≥2 compra la "
"gamba debole/shorta la forte, chiude a |z|≤0.75 o 72 barre. Scorrelato dal mercato (~0.05). "
"Anche ETH/BTC a 15m (flat-skip). Fee su 2 gambe, senza stop → size ridotta.",
"SH01": "Shape-ML — una LogisticRegression legge 17 feature di forma in walk-forward e predice il "
"segno del rendimento a 12 barre. Win-rate ~50%: l'edge è nell'asimmetria. Diversificatore, no stop.",
"TR01": "Basket Trend (4h) — long quando EMA20>EMA100 su paniere equal-weight di 5 asset, flat "
"altrimenti. Trend-following difensivo che cattura i trend lunghi che le fade non prendono.",
"ROT02": "Dual Momentum (1d) — ogni giorno tiene le top-3 per momentum 60g (se positive), gross 0.45, "
"tutto cash se BTC<SMA100. Rotazione multi-crypto.",
"TSM01": "TSMOM (1d) — long sugli asset con consenso pieno di momentum su 3/6/12 mesi, gross 0.30, "
"risk-off se BTC<SMA100. Diversificatore, non motore di ritorno.",
"XS01": "Cross-Sectional Reversion — ogni 12h classifica 8 crypto per momentum e va long i perdenti "
"relativi / short i vincenti (market-neutral), con dispersion-gate. 3 sub-book sfasati.",
}
# Versione di creazione/ultima modifica significativa per strategia (fonte: CLAUDE.md + diari)
VERSIONS = {
"MR01": "Fade storica · ultima modifica v1.1.30 — swap 1h→15m (2026-06-12)",
"MR02": "Fade storica · v1.1.30 swap 15m (06-12) · MR02/ETH stop-largo (2026-06-09)",
"MR07": "Fade storica · ultima modifica v1.1.30 — swap 1h→15m (2026-06-12)",
"DIP01": "Exec reale v1.0.3 (2026-06-04) · EXIT-16 esteso (2026-06-07)",
"PR01": "Exec reale 2 gambe v1.1.12 (2026-06-08) · blend ETH/BTC 15m v1.1.16 (2026-06-09)",
"SH01": "Live 2026-06-01 · train full-history (06-07) · exec reale v1.1.13 (2026-06-08)",
"TR01": "Honest, paper · fix mean(rets) (2026-06-11)",
"ROT02": "Honest, paper · top_k=3 DD 40→26%",
"TSM01": "Paper · diversificatore risk-off",
"XS01": "Attivo 2026-06-09 · dispersion-gate v1.1.20 (06-10) · phase-tranching (06-11)",
}
def _app_version() -> str:
try:
from src.version import APP_VERSION
return str(APP_VERSION)
except Exception:
return "?"
def _code_of(wid: str) -> str:
for code, _ in FAMILY:
if wid.startswith(code):
return code
return "?"
_MARK_CACHE: dict = {"ts": 0.0, "marks": {}}
def _family_of(wid: str) -> str:
for code, fam in FAMILY:
if wid.startswith(code):
return fam
return "?"
def _short(wid: str) -> str:
return (wid.replace("_bollinger_fade", "").replace("_donchian_fade", "")
.replace("_return_reversal", "").replace("_pairs_reversion", "")
.replace("_shape_ml", "").replace("_dip_buy", "").replace("_basket", "")
.replace("_rot", "").replace("__", " "))
def _age_min(path: Path) -> float:
return (time.time() - path.stat().st_mtime) / 60.0
def _marks(assets: set[str]) -> dict[str, float]:
"""Mark correnti (USDC perp) best-effort, cache 20s. Vuoto se Cerbero non risponde."""
if not assets:
return {}
if time.time() - _MARK_CACHE["ts"] < 20:
return _MARK_CACHE["marks"]
try:
from src.live.cerbero_client import CerberoClient
insts = [f"{a}_USDC-PERPETUAL" for a in assets]
data = CerberoClient().get_ticker_batch(insts)
marks = {t["instrument_name"].split("_")[0].replace("-PERPETUAL", ""): t.get("mark_price")
for t in data.get("tickers", [])}
_MARK_CACHE.update(ts=time.time(), marks=marks)
return marks
except Exception:
return _MARK_CACHE["marks"]
def _equity_curve(max_pts: int = 400) -> list[dict]:
f = LEDGER / "equity.jsonl"
if not f.exists():
return []
rows = [json.loads(l) for l in f.read_text().splitlines() if l.strip()]
if len(rows) > max_pts: # downsample uniforme
step = len(rows) / max_pts
rows = [rows[int(i * step)] for i in range(max_pts)]
return [{"t": r["ts"], "equity": r["equity"], "dd": r.get("dd", 0.0)} for r in rows]
def _ledger_pnl(equity: float) -> tuple[float, float]:
"""(pnl_total, capitale iniziale) dal ledger — MAI hardcoded. La equity.jsonl scrive
`pnl_total = equity initial_capital` ad ogni tick → e' la fonte di verita' (il
micro-test mainnet parte da 500, il testnet partiva da 2000; lo status non persiste
l'iniziale). Da pnl_total derivo l'iniziale (equity pnl_total) cosi' la dashboard
combacia col ledger per costruzione. Fallback: primo punto equity, poi l'equity stessa."""
f = LEDGER / "equity.jsonl"
if f.exists():
lines = [l for l in f.read_text().splitlines() if l.strip()]
if lines:
try:
last = json.loads(lines[-1])
if last.get("pnl_total") is not None:
pt = float(last["pnl_total"])
return pt, equity - pt
init = float(json.loads(lines[0]).get("equity", equity))
return equity - init, init
except Exception:
pass
return 0.0, equity
def _worker_trades(wid: str) -> tuple[float, int, int, list[dict]]:
"""(pnl realizzato, n_chiusi, n_win, lista CLOSE) dal trades.jsonl."""
f = PAPER / wid / "trades.jsonl"
if not f.exists():
f = STATS / wid / "trades.jsonl"
pnl = 0.0
wins = n = 0
closes = []
if f.exists():
for line in f.read_text().splitlines():
if not line.strip():
continue
try:
e = json.loads(line)
except Exception:
continue
if e.get("event") != "CLOSE":
continue
p = e.get("pnl", 0.0) or 0.0
pnl += p
n += 1
win = bool(e.get("win", p > 0))
wins += win
closes.append({"ts": e.get("ts", ""), "worker": _short(wid),
"reason": e.get("reason", "?"), "pnl": round(p, 3),
"sim_pnl": e.get("sim_pnl"), "real_pnl": e.get("real_pnl"),
"win": win, "src": e.get("pnl_source", "")})
return pnl, n, wins, closes
def build_state() -> dict:
now = datetime.now(timezone.utc)
# --- ledger / portfolio ---
led = json.loads((LEDGER / "status.json").read_text()) if (LEDGER / "status.json").exists() else {}
curve = _equity_curve()
equity = led.get("equity", curve[-1]["equity"] if curve else 0.0)
pnl_total, init_cap = _ledger_pnl(equity) # dal ledger, NON hardcoded (500 mainnet / 2000 testnet)
strategies, active, closed = [], [], []
paper_strategies, paper_closed = [], []
open_assets: set[str] = set()
dirs = sorted(list(PAPER.glob("*")) + list(STATS.glob("*")))
for d in dirs:
sp = d / "status.json"
if not sp.exists():
continue
wid = d.name
st = json.loads(sp.read_text())
paper_only = (d.parent.name == "portfolio_paper_stats") # multi-asset (no real exec)
pnl, n, wins, closes = _worker_trades(wid)
for c in closes: # tag famiglia per le curve aggregate
c["fam"] = _family_of(wid)
(paper_closed if paper_only else closed).extend(closes)
cap = st.get("capital", 0.0)
rc = st.get("real_capital")
# ritirata = status.json non aggiornato da >30min (non piu' tickato dal runner =
# fuori dal config attivo, es. le fade 1h sostituite dal 15m). I paper/stats
# tickano sempre -> mai ritirati.
retired = (not paper_only) and _age_min(sp) > RETIRED_MIN
(paper_strategies if paper_only else strategies).append({
"id": _short(wid), "wid": wid, "family": _family_of(wid), "code": _code_of(wid),
"capital": round(cap, 2), "real_capital": round(rc, 2) if rc is not None else None,
"realized_pnl": round(pnl, 2), "n_trades": n, "wins": wins,
"win_rate": round(wins / n * 100, 0) if n else 0.0,
"in_position": bool(st.get("in_position")), "paper": paper_only,
"retired": retired, "tf": wid.split("__")[-1],
"desc": DESCRIPTIONS.get(_code_of(wid), ""),
"version": VERSIONS.get(_code_of(wid), ""),
})
if st.get("in_position") and not paper_only:
is_pair = "entry_a" in st
# SEMPRE prezzi REALI (entry reale di esecuzione vs mark reale USDC): il feed
# di decisione SIM (testnet inverse) è dislocato e non va mostrato come prezzo.
executed = st.get("real_in_position")
et = st.get("entry_time", "")
held = None
if et:
try:
t0 = datetime.fromisoformat(et.replace("Z", "+00:00"))
held = round((now - t0).total_seconds() / 60.0, 1)
except Exception:
held = None
row = {
"id": _short(wid), "family": _family_of(wid),
"dir": "LONG" if st.get("direction", 0) > 0 else "SHORT",
"bars": st.get("bars_held", 0), "max_bars": st.get("max_bars", 0),
"tp": st.get("tp", 0.0), "age_min": round(_age_min(sp), 1),
"stale": _age_min(sp) > 15, "pair": is_pair,
"real": bool(executed),
"entry_time": et, "held_min": held,
}
if is_pair:
a_, b_ = wid.split("__")[1].split("_") # es. ETH_SOL
open_assets.update({a_, b_})
row.update({
"asset": None, "leg_a": a_, "leg_b": b_,
"entry_a": st.get("real_entry_a") if executed else st.get("entry_a"),
"entry_b": st.get("real_entry_b") if executed else st.get("entry_b"),
"notional_a": st.get("real_notional_a") or 0.0,
"notional_b": st.get("real_notional_b") or 0.0,
"dirnum": 1 if st.get("direction", 0) > 0 else -1,
"entry": round(st.get("real_entry_a") or st.get("entry_a") or 0.0, 4),
})
else:
asset = wid.split("__")[1]
open_assets.add(asset)
entry = (st.get("real_entry_price") if executed else None) or st.get("entry_price") or 0.0
row.update({"asset": asset, "entry": round(entry, 4),
"notional": st.get("real_entry_notional") or round(cap * 0.5 * 3, 1)})
active.append(row)
# valore di mercato REALE + PnL non realizzato reale (mark USDC live, best-effort)
marks = _marks(open_assets)
for a in active:
if not a["pair"] and a.get("asset") and marks.get(a["asset"]) and a["entry"]:
mark = marks[a["asset"]]
sign = 1 if a["dir"] == "LONG" else -1
pct = sign * (mark - a["entry"]) / a["entry"]
a["mark"] = round(mark, 4)
a["unreal_pct"] = round(pct * 100, 2)
a["unreal_eur"] = round(a["notional"] * pct, 2)
if a["tp"]:
a["to_tp_pct"] = round(abs(a["tp"] - mark) / mark * 100, 2)
elif a["pair"]:
ma, mb = marks.get(a["leg_a"]), marks.get(a["leg_b"])
if ma and mb and a["entry_a"] and a["entry_b"]:
s = a["dirnum"] # +1 long_ratio: long A / short B
gain_a = a["notional_a"] * s * (ma - a["entry_a"]) / a["entry_a"]
gain_b = a["notional_b"] * (-s) * (mb - a["entry_b"]) / a["entry_b"]
a["mark"] = round(ma, 4)
a["mark_b"] = round(mb, 4)
a["unreal_eur"] = round(gain_a + gain_b, 2)
# curve EQUITY per FAMIGLIA: PnL cumulato di ogni famiglia su asse-tempo comune
# (ad ogni CLOSE di una qualsiasi famiglia, il cumulato di TUTTE avanza step-wise).
real_asc = sorted(closed, key=lambda c: c["ts"])
fams = sorted({c["fam"] for c in real_asc})
fcum = {f: 0.0 for f in fams}
flabels: list[str] = []
fseries: dict[str, list[float]] = {f: [] for f in fams}
for c in real_asc:
fcum[c["fam"]] += c["pnl"] or 0.0
flabels.append(c["ts"])
for f in fams:
fseries[f].append(round(fcum[f], 3))
fam_curves = {"labels": flabels, "series": fseries}
closed.sort(key=lambda c: c["ts"], reverse=True)
# aggregati per famiglia (SOLO reali: il paper ha la sua area)
fam_pnl: dict[str, float] = {}
for s in strategies:
fam_pnl[s["family"]] = fam_pnl.get(s["family"], 0.0) + s["realized_pnl"]
# --- area PAPER distinta: equity propria = capitale-base + PnL cumulato del book ---
paper_init = round(sum(s["capital"] - s["realized_pnl"] for s in paper_strategies), 2)
paper_closed.sort(key=lambda c: c["ts"])
pcurve, acc = [], paper_init
for c in paper_closed:
acc += c["pnl"] or 0.0
pcurve.append({"t": c["ts"], "equity": round(acc, 2)})
paper_pnl = round(sum(s["realized_pnl"] for s in paper_strategies), 2)
paper_cap = round(sum(s["capital"] for s in paper_strategies), 2)
return {
"ts": now.isoformat(),
"version": _app_version(),
"portfolio": {
"equity": round(equity, 2), "init": round(init_cap, 2),
"pnl_total": round(pnl_total, 2),
"pnl_pct": round((pnl_total / init_cap * 100) if init_cap else 0.0, 2),
"dd": led.get("max_dd", 0.0), "peak": led.get("peak", equity),
"last_rebalance": led.get("last_rebalance", ""),
"curve": curve,
},
"fam_pnl": {k: round(v, 2) for k, v in sorted(fam_pnl.items())},
"fam_curves": fam_curves,
"descriptions": DESCRIPTIONS,
# ritirate in fondo, poi per famiglia/id
"strategies": sorted(strategies, key=lambda s: (s["retired"], s["family"], s["id"])),
"active": sorted(active, key=lambda a: a.get("unreal_eur", 0)),
"closed": closed[:50],
"n_active": len(active),
# area PAPER (multi-asset TR01/ROT02/TSM01/XS01: solo statistica, fuori dal pool reale)
"paper": {
"strategies": sorted(paper_strategies, key=lambda s: s["id"]),
"curve": pcurve, "init": paper_init, "equity": paper_cap, "pnl": paper_pnl,
"n": len(paper_strategies),
},
}
def strategy_detail(wid: str) -> dict:
"""Dettaglio di una strategia: descrizione + curva PnL cumulato (reale e sim) dai
suoi CLOSE + lista trade. Alimenta il modal 'apri scheda strategia'."""
code = _code_of(wid)
d = PAPER / wid
if not d.exists():
d = STATS / wid
sp = d / "status.json"
st = json.loads(sp.read_text()) if sp.exists() else {}
_, _, _, closes = _worker_trades(wid)
closes.sort(key=lambda c: c["ts"])
curve, cr, cs = [], 0.0, 0.0
for c in closes:
cr += (c["real_pnl"] if c["real_pnl"] is not None else c["pnl"]) or 0.0
cs += (c["sim_pnl"] if c["sim_pnl"] is not None else c["pnl"]) or 0.0
curve.append({"t": c["ts"], "real": round(cr, 3), "sim": round(cs, 3)})
pos = None
if st.get("in_position"):
pos = {"dir": "LONG" if st.get("direction", 0) > 0 else "SHORT",
"entry": st.get("entry_price") or st.get("entry_a"),
"bars": st.get("bars_held"), "max_bars": st.get("max_bars"),
"tp": st.get("tp"), "sl": st.get("sl")}
return {"id": _short(wid), "code": code, "family": _family_of(wid),
"desc": DESCRIPTIONS.get(code, ""), "version": VERSIONS.get(code, ""),
"tf": wid.split("__")[-1],
"retired": (d.parent.name != "portfolio_paper_stats") and _age_min(sp) > RETIRED_MIN,
"paper": d.parent.name == "portfolio_paper_stats",
"position": pos, "curve": curve,
"trades": list(reversed(closes))[:60]}
# ----------------------- HTTP -----------------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body.encode())
def do_GET(self):
from urllib.parse import urlparse, parse_qs
p = urlparse(self.path)
if p.path == "/api/state":
try:
self._send(200, json.dumps(build_state()), "application/json")
except Exception as e:
self._send(500, json.dumps({"error": str(e)}), "application/json")
elif p.path == "/api/strategy":
wid = (parse_qs(p.query).get("wid") or [""])[0]
# difesa path-traversal: solo nome cartella semplice
if not wid or "/" in wid or ".." in wid:
self._send(400, json.dumps({"error": "wid invalido"}), "application/json")
return
try:
self._send(200, json.dumps(strategy_detail(wid)), "application/json")
except Exception as e:
self._send(500, json.dumps({"error": str(e)}), "application/json")
elif p.path == "/report/strategie_attive.html":
f = PROJECT_ROOT / "docs" / "report" / "strategie_attive.html"
if f.exists():
self._send(200, f.read_text(), "text/html; charset=utf-8")
else:
self._send(404, "scheda non disponibile (docs/report non montato)", "text/plain")
elif p.path == "/" or p.path.startswith("/index"):
self._send(200, HTML, "text/html; charset=utf-8")
else:
self._send(404, "not found", "text/plain")
HTML = r"""<!doctype html><html lang="it"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PORT06 — Dashboard</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--bd:#30363d;--tx:#c9d1d9;--mut:#8b949e;--grn:#3fb950;--red:#f85149;--acc:#58a6ff}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--tx);font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif}
.wrap{max-width:1200px;margin:0 auto;padding:16px}
h1{font-size:18px;margin:0 0 2px}.sub{color:var(--mut);font-size:12px;margin-bottom:14px}
.grid{display:grid;gap:12px}.kpis{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
.card{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:14px}
.kpi .v{font-size:24px;font-weight:600}.kpi .l{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.grn{color:var(--grn)}.red{color:var(--red)}.mut{color:var(--mut)}
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:right;padding:6px 8px;border-bottom:1px solid var(--bd)}
th:first-child,td:first-child{text-align:left}th{color:var(--mut);font-weight:500;font-size:11px;text-transform:uppercase}
.bar{height:18px;border-radius:4px;display:inline-block;vertical-align:middle}
.tag{font-size:10px;padding:1px 6px;border-radius:10px;background:#21262d;color:var(--mut);margin-left:6px}
.sec{margin-top:18px}.sec h2{font-size:14px;margin:0 0 8px;color:var(--acc)}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px}
.pill{font-size:11px;padding:1px 7px;border-radius:10px}.long{background:#0f3d2e;color:var(--grn)}.short{background:#3d1418;color:var(--red)}
.stale{color:#d29922}.muted-row td{color:var(--mut)}
.retired-row td{color:#6e7681;opacity:.7}.retired-row .id{text-decoration:line-through}
.badge-ret{background:#3d1418;color:#f85149}.badge-paper{background:#21262d;color:var(--mut)}
.desc{font-size:11px;color:var(--mut);margin-top:2px;max-width:520px}
.dl b{color:var(--acc)}.dl div{padding:5px 0;border-bottom:1px solid var(--bd)}
.ver{font-size:10px;color:#6e7681;margin-top:2px}
.clk{cursor:pointer}.clk:hover .id{color:var(--acc)}
.ov{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:10;align-items:flex-start;justify-content:center;overflow:auto}
.ov.on{display:flex}.modal{background:var(--card);border:1px solid var(--bd);border-radius:12px;max-width:820px;width:94%;margin:40px 0;padding:20px}
.modal h3{margin:0 0 4px}.x{float:right;cursor:pointer;color:var(--mut);font-size:20px;line-height:1}
.btn{display:inline-block;background:#1f6feb;color:#fff;padding:7px 12px;border-radius:7px;text-decoration:none;font-size:13px;margin-top:10px}
/* contenitore ad altezza fissa + canvas responsive: l'hit-test del tooltip combacia
col mouse (forzare l'altezza del canvas via CSS sfalsa la risoluzione interna) */
.chartbox{position:relative;width:100%}.chartbox canvas{width:100%!important;height:100%!important}
.chartbox.eq{height:300px}.chartbox.peq{height:200px}.chartbox.m{height:240px}
.paperzone{margin-top:22px;border:1px dashed #6e552233;border-radius:12px;padding:14px;background:linear-gradient(180deg,rgba(210,153,34,.05),transparent)}
#eqcard{background:linear-gradient(180deg,#11161d,#0d1117)}
.eqhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px}
.eqhead .big{font-size:22px;font-weight:600}.eqhead .pc{font-size:13px}
</style></head><body><div class="wrap">
<h1>PORT06 — Dashboard live <span id="ver" class="tag"></span></h1>
<div class="sub">aggiornato <span id="ts">—</span> · refresh 5s · <span id="nact">0</span> posizioni aperte</div>
<div class="grid kpis" id="kpis"></div>
<div class="card sec" id="eqcard"><div class="eqhead"><h2 style="margin:0">Equity</h2>
<div><span class="big" id="eqval">—</span> <span class="pc" id="eqpc"></span></div></div>
<div class="chartbox eq"><canvas id="eq"></canvas></div></div>
<div class="card sec"><h2>Trade attivi — stato in tempo reale</h2><div id="active"></div></div>
<div class="card sec"><h2>Equity per famiglia <span class="mut" style="font-size:12px">(PnL cumulato realizzato)</span></h2>
<div class="chartbox" style="height:240px"><canvas id="famchart"></canvas></div></div>
<div class="card sec"><h2>Strategie REALI per famiglia <span class="mut" style="font-size:12px">(realizzato, netto fee)</span></h2><div id="strat"></div></div>
<div class="card sec"><h2>Trade chiusi (ultimi 50)</h2><div id="closed"></div></div>
<div class="paperzone">
<div class="eqhead"><h2 style="margin:0;color:#d29922">📄 Area PAPER — multi-asset (solo statistica, fuori dal pool reale)</h2>
<div><span class="big" id="peqval">—</span> <span class="pc" id="peqpc"></span></div></div>
<div class="sub">TR01 / ROT02 / TSM01 / XS01 girano con capitale nozionale fisso per valutarne l'edge in vista di un'esecuzione reale futura (bloccata dal capitale a €2k). Equity e PnL separati dal portafoglio reale.</div>
<div class="card" style="margin:10px 0"><div class="chartbox peq"><canvas id="peq"></canvas></div></div>
<div class="card"><div id="pstrat"></div></div>
</div>
<div class="card sec"><h2>Descrizioni strategie</h2><div id="descs"></div></div>
</div>
<div class="ov" id="ov"><div class="modal">
<span class="x" id="mx">✕</span>
<h3 id="mtitle">—</h3><div class="sub" id="msub"></div>
<div class="desc" id="mdesc" style="max-width:none"></div>
<div class="ver" id="mver"></div>
<div id="mpos" class="sub"></div>
<h2 style="font-size:13px;color:var(--acc);margin:14px 0 6px">PnL cumulato (reale vs sim)</h2>
<div class="chartbox m"><canvas id="mchart"></canvas></div><div id="mempty" class="mut"></div>
<a class="btn" id="mfull" href="/report/strategie_attive.html" target="_blank">📊 Scheda dettagliata con grafici della strategia</a>
<h2 style="font-size:13px;color:var(--acc);margin:16px 0 6px">Trade di questa strategia</h2>
<div id="mtrades"></div>
</div></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
const E=(s,...c)=>{const e=document.createElement(s);c.forEach(x=>e.append(x));return e};
const eur=n=>(n>=0?'+':'')+n.toFixed(2)+'', cls=n=>n>=0?'grn':'red';
let chart=null;
function kpi(l,v,c){const d=E('div');d.className='card kpi';const a=E('div');a.className='l';a.textContent=l;
const b=E('div');b.className='v '+(c||'');b.textContent=v;d.append(a,b);return d}
function renderKpis(p){const k=document.getElementById('kpis');k.innerHTML='';
k.append(kpi('Equity',''+p.equity.toFixed(2)),
kpi('PnL totale',eur(p.pnl_total)+' ('+p.pnl_pct.toFixed(2)+'%)',cls(p.pnl_total)),
kpi('Max Drawdown',p.dd.toFixed(2)+'%'),
kpi('Peak',''+p.peak.toFixed(2)));}
function renderChart(curve,init){const ctx=document.getElementById('eq');
const labels=curve.map(c=>c.t.slice(5,16).replace('T',' ')),data=curve.map(c=>c.equity);
const last=data[data.length-1],up=last>=init;
const eqv=document.getElementById('eqval'),eqp=document.getElementById('eqpc');
if(eqv){eqv.textContent=''+last.toFixed(2);eqp.textContent=(up?'▲ +':'')+(last-init).toFixed(2)+'';eqp.className='pc '+(up?'grn':'red');}
if(!window.Chart){if(ctx)ctx.replaceWith(E('div','grafico non disponibile (Chart.js offline)'));return;}
const g=ctx.getContext('2d'),grad=g.createLinearGradient(0,0,0,300);
const col=up?'63,185,80':'248,81,73';
grad.addColorStop(0,`rgba(${col},.35)`);grad.addColorStop(1,`rgba(${col},0)`);
if(chart){chart.data.labels=labels;chart.data.datasets[0].data=data;
chart.data.datasets[0].borderColor=`rgb(${col})`;chart.data.datasets[0].backgroundColor=grad;
chart.options.plugins.annLine=init;chart.update('none');return;}
chart=new Chart(ctx,{type:'line',data:{labels,datasets:[{data,borderColor:`rgb(${col})`,
backgroundColor:grad,fill:true,pointRadius:0,pointHoverRadius:5,pointHoverBackgroundColor:'#fff',
borderWidth:2.5,tension:.25}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:false},
tooltip:{backgroundColor:'#161b22',borderColor:'#30363d',borderWidth:1,titleColor:'#8b949e',
bodyColor:'#c9d1d9',padding:10,displayColors:false,
callbacks:{label:c=>''+c.parsed.y.toFixed(2)+' ('+(c.parsed.y-init>=0?'+':'')+(c.parsed.y-init).toFixed(2)+'€)'}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:8,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>''+v},grid:{color:'rgba(48,54,61,.5)'},
suggestedMin:Math.min(init,...data)-2,suggestedMax:Math.max(init,...data)+2}}}});}
const FAMCOL={FADE:'#3fb950',PAIRS:'#58a6ff',SHAPE:'#bc8cff',HONEST:'#d29922',TSM:'#39c5cf',XSEC:'#f778ba'};
let famchart=null;
function renderFamChart(fc,fpnl){const ctx=document.getElementById('famchart');if(!ctx||!window.Chart)return;
const labels=(fc.labels||[]).map(t=>t.slice(5,16).replace('T',' '));
const ds=Object.keys(fc.series||{}).map(f=>({label:f+' ('+eur(fpnl[f]||0)+')',data:fc.series[f],
borderColor:FAMCOL[f]||'#8b949e',backgroundColor:'transparent',pointRadius:0,pointHoverRadius:4,borderWidth:2,tension:.15}));
if(famchart){famchart.data.labels=labels;famchart.data.datasets=ds;famchart.update('none');return;}
famchart=new Chart(ctx,{type:'line',data:{labels,datasets:ds},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},
plugins:{legend:{labels:{color:'#c9d1d9',boxWidth:12,font:{size:11}}},
tooltip:{callbacks:{label:c=>c.dataset.label.split(' ')[0]+': '+eur(c.parsed.y)}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:8,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>eur(v)},grid:{color:'rgba(48,54,61,.5)'}}}}});}
function stratRow(s,max){const tr=E('tr');tr.className=(s.retired?'retired-row':(s.paper?'muted-row':''))+' clk';
tr.onclick=()=>openModal(s.wid);
const w=Math.abs(s.realized_pnl)/max*120;
const bar=`<span class="bar" style="width:${w}px;background:${s.realized_pnl>=0?'#3fb950':'#f85149'}"></span>`;
const badges=(s.retired?' <span class="tag badge-ret">RITIRATA→15m</span>':'')
+(s.in_position?' <span class=tag>•aperta</span>':'');
tr.innerHTML=`<td><span class=id title="apri scheda">${s.id}</span>${badges}
<div class=desc>${s.desc||''}</div><div class=ver>🏷 ${s.version||''}</div></td>
<td class="${cls(s.realized_pnl)}">${eur(s.realized_pnl)}</td>
<td>${s.n_trades}</td><td class=mut>${s.win_rate.toFixed(0)}</td><td class=mut>€${s.capital.toFixed(0)}</td><td>${bar}</td>`;
return tr;}
function renderStrat(strats,fpnl){const wrap=document.getElementById('strat');wrap.innerHTML='';
const max=Math.max(100,...strats.map(s=>Math.abs(s.realized_pnl)));
// raggruppa per famiglia, attive prima delle ritirate
const byfam={};strats.forEach(s=>{(byfam[s.family]=byfam[s.family]||[]).push(s);});
Object.keys(byfam).sort().forEach(fam=>{
const head=E('div');head.style.cssText='display:flex;justify-content:space-between;align-items:center;margin:14px 0 4px';
const fp=fpnl[fam]||0;
head.innerHTML=`<span style="font-weight:600;color:${FAMCOL[fam]||'#c9d1d9'}">▌${fam}</span>`
+`<span class="${cls(fp)}">${eur(fp)}</span>`;
wrap.append(head);
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>PnL realizz.</th><th>Trade</th><th>Win%</th><th>Capitale</th><th></th></tr>';
byfam[fam].sort((a,b)=>(a.retired-b.retired)||a.id.localeCompare(b.id))
.forEach(s=>t.append(stratRow(s,max)));
wrap.append(t);});}
let mchart=null;
async function openModal(wid){const ov=document.getElementById('ov');ov.classList.add('on');
document.getElementById('mtitle').textContent='caricamento…';
try{const r=await fetch('/api/strategy?wid='+encodeURIComponent(wid));const d=await r.json();
document.getElementById('mtitle').textContent=d.id+' ['+d.code+']';
document.getElementById('msub').textContent=d.family+' · '+d.tf+(d.retired?' · RITIRATA':'')+(d.paper?' · paper':'');
document.getElementById('mdesc').textContent=d.desc||'';
document.getElementById('mver').innerHTML='🏷 <b>versione:</b> '+(d.version||'');
document.getElementById('mpos').innerHTML=d.position?`posizione aperta: <b>${d.position.dir}</b> @${d.position.entry} · barre ${d.position.bars}/${d.position.max_bars||''}`:'';
// curva PnL cumulato
const c=d.curve||[];const lab=c.map(x=>x.t.slice(5,16).replace('T',' '));
const em=document.getElementById('mempty');
if(!c.length){em.textContent='nessun trade ancora — la curva apparirà col primo trade chiuso.';}
else em.textContent='';
if(mchart){mchart.destroy();mchart=null;}
if(c.length&&window.Chart){mchart=new Chart(document.getElementById('mchart'),{type:'line',
data:{labels:lab,datasets:[
{label:'reale',data:c.map(x=>x.real),borderColor:'#3fb950',backgroundColor:'rgba(63,185,80,.08)',fill:true,pointRadius:0,borderWidth:2,tension:.15},
{label:'sim',data:c.map(x=>x.sim),borderColor:'#8b949e',borderDash:[5,4],pointRadius:0,borderWidth:1.5,tension:.15}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{labels:{color:'#c9d1d9'}}},scales:{x:{ticks:{color:'#8b949e',maxTicksLimit:7},grid:{display:false}},y:{ticks:{color:'#8b949e'},grid:{color:'#21262d'}}}}});}
// trade
const mt=document.getElementById('mtrades');
if(!d.trades.length){mt.innerHTML='<div class=mut>nessun trade</div>';}
else{const t=E('table');t.innerHTML='<tr><th>Ora</th><th>Motivo</th><th>PnL</th><th>sim</th><th>esito</th></tr>';
d.trades.forEach(x=>{const tr=E('tr');tr.innerHTML=`<td class=mut>${x.ts.slice(5,16).replace('T',' ')}</td>
<td class=mut>${x.reason}</td><td class="${cls(x.pnl)}">${eur(x.pnl)}</td>
<td class=mut>${x.sim_pnl!=null?x.sim_pnl.toFixed(2):''}</td>
<td><span class="dot" style="background:${x.win?'#3fb950':'#f85149'}"></span>${x.win?'win':'loss'}</td>`;t.append(tr);});
mt.innerHTML='';mt.append(t);}
}catch(e){document.getElementById('mtitle').textContent='errore: '+e;}}
document.getElementById('mx').onclick=()=>document.getElementById('ov').classList.remove('on');
document.getElementById('ov').onclick=e=>{if(e.target.id=='ov')document.getElementById('ov').classList.remove('on');};
let peqchart=null;
function renderPaper(p){
const v=document.getElementById('peqval'),pc=document.getElementById('peqpc');
const up=p.pnl>=0;v.textContent=''+p.equity.toFixed(2);
pc.textContent=(up?'▲ +':'')+p.pnl.toFixed(2)+'€ realizz.';pc.className='pc '+(up?'grn':'red');
// tabella paper (riuso lo stile della tabella strategie)
const wrap=document.getElementById('pstrat');wrap.innerHTML='';
const max=Math.max(50,...p.strategies.map(s=>Math.abs(s.realized_pnl)));
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>Fam</th><th>PnL realizz.</th><th>Trade</th><th>Win%</th><th>Capitale</th><th></th></tr>';
p.strategies.forEach(s=>{const tr=E('tr');tr.className='clk';tr.onclick=()=>openModal(s.wid);
const w=Math.abs(s.realized_pnl)/max*120;
const bar=`<span class="bar" style="width:${w}px;background:${s.realized_pnl>=0?'#3fb950':'#f85149'}"></span>`;
tr.innerHTML=`<td><span class=id>${s.id}</span> <span class="tag badge-paper">paper</span>${s.in_position?' <span class=tag>•aperta</span>':''}
<div class=desc>${s.desc||''}</div><div class=ver>🏷 ${s.version||''}</div></td>
<td class=mut>${s.family}</td><td class="${cls(s.realized_pnl)}">${eur(s.realized_pnl)}</td>
<td>${s.n_trades}</td><td class=mut>${s.win_rate.toFixed(0)}</td><td class=mut>€${s.capital.toFixed(0)}</td><td>${bar}</td>`;
t.append(tr);});wrap.append(t);
// curva equity paper
const c=p.curve||[];const ctx=document.getElementById('peq');
if(!window.Chart||!ctx)return;
const lab=c.map(x=>x.t.slice(5,16).replace('T',' ')),data=c.map(x=>x.equity);
if(peqchart){peqchart.data.labels=lab;peqchart.data.datasets[0].data=data;peqchart.update('none');return;}
peqchart=new Chart(ctx,{type:'line',data:{labels:lab.length?lab:['inizio'],datasets:[{
data:data.length?data:[p.init],borderColor:'#d29922',backgroundColor:'rgba(210,153,34,.12)',
fill:true,pointRadius:c.length<30?3:0,borderWidth:2,tension:.2}]},
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{display:false},tooltip:{callbacks:{label:x=>''+x.parsed.y.toFixed(2)}}},
scales:{x:{ticks:{color:'#6e7681',maxTicksLimit:7,font:{size:10}},grid:{display:false}},
y:{ticks:{color:'#6e7681',callback:v=>''+v},grid:{color:'rgba(48,54,61,.5)'}}}}});}
function renderDescs(d){const wrap=document.getElementById('descs');if(!wrap)return;wrap.innerHTML='';
const box=E('div');box.className='dl';
Object.keys(d).forEach(k=>{const r=E('div');r.innerHTML=`<b>${k}</b> — ${d[k]}`;box.append(r);});
wrap.append(box);}
function renderActive(a){const wrap=document.getElementById('active');wrap.innerHTML='';
if(!a.length){wrap.innerHTML='<div class=mut>nessuna posizione aperta</div>';return;}
const t=E('table');t.innerHTML='<tr><th>Strategia</th><th>Lato</th><th>Ingresso (UTC)</th><th>In posizione</th><th>Entry reale</th><th>Mercato reale</th><th>PnL non realizz.</th><th>Barre</th><th>→TP%</th></tr>';
a.forEach(p=>{const tr=E('tr');
const side=`<span class="pill ${p.dir=='LONG'?'long':'short'}">${p.dir}${p.pair?' ratio':''}</span>`;
const ue=p.unreal_eur!=null?`<span class="${cls(p.unreal_eur)}">${eur(p.unreal_eur)}${p.unreal_pct!=null?' ('+p.unreal_pct+'%)':''}</span>`:'<span class=mut>—</span>';
const mkt=p.pair?(p.mark!=null?`${p.mark} / ${p.mark_b}`:''):(p.mark||'');
const ing=p.entry_time?p.entry_time.slice(5,16).replace('T',' '):'';
const held=fmtDur(p.held_min);
tr.innerHTML=`<td>${p.id}${p.real?'':' <span class=tag>sim</span>'}${p.pair?' <span class=tag>'+p.leg_a+'/'+p.leg_b+'</span>':''}</td><td>${side}</td>
<td class=mut>${ing}</td><td class="${p.stale?'stale':''}">${held}${p.stale?'':''}</td>
<td>${p.entry}</td><td>${mkt}</td><td>${ue}</td>
<td>${p.bars}/${p.max_bars||''}</td><td class=mut>${p.to_tp_pct!=null?p.to_tp_pct:''}</td>`;
t.append(tr);});wrap.append(t);}
function fmtDur(m){if(m==null)return '';m=Math.round(m);if(m<60)return m+'m';
const h=Math.floor(m/60),mm=m%60;if(h<24)return h+'h '+mm+'m';
const d=Math.floor(h/24);return d+'g '+(h%24)+'h';}
function renderClosed(c){const wrap=document.getElementById('closed');wrap.innerHTML='';
if(!c.length){wrap.innerHTML='<div class=mut>nessun trade chiuso</div>';return;}
const t=E('table');t.innerHTML='<tr><th>Ora (UTC)</th><th>Strategia</th><th>Motivo</th><th>PnL</th><th>sim</th><th>esito</th></tr>';
c.forEach(x=>{const tr=E('tr');
tr.innerHTML=`<td class=mut>${x.ts.slice(5,16).replace('T',' ')}</td><td>${x.worker}</td>
<td class=mut>${x.reason}</td><td class="${cls(x.pnl)}">${eur(x.pnl)}</td>
<td class=mut>${x.sim_pnl!=null?x.sim_pnl.toFixed(2):''}</td>
<td><span class="dot" style="background:${x.win?'#3fb950':'#f85149'}"></span>${x.win?'win':'loss'}</td>`;
t.append(tr);});wrap.append(t);}
async function tick(){try{const r=await fetch('/api/state');const s=await r.json();
if(s.error){document.getElementById('ts').textContent='errore: '+s.error;return;}
document.getElementById('ts').textContent=s.ts.slice(11,19);
document.getElementById('ver').textContent='v'+(s.version||'?');
document.getElementById('nact').textContent=s.n_active;
renderKpis(s.portfolio);renderChart(s.portfolio.curve,s.portfolio.init);
renderFamChart(s.fam_curves,s.fam_pnl);renderStrat(s.strategies,s.fam_pnl);renderActive(s.active);renderClosed(s.closed);
if(s.paper)renderPaper(s.paper);renderDescs(s.descriptions);
}catch(e){document.getElementById('ts').textContent='offline';}}
tick();setInterval(tick,5000);
</script></body></html>"""
def main():
port = 8787
if "--port" in sys.argv:
port = int(sys.argv[sys.argv.index("--port") + 1])
srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print(f"[dashboard] PORT06 live su http://0.0.0.0:{port} (Ctrl-C per fermare)")
try:
srv.serve_forever()
except KeyboardInterrupt:
print("\n[dashboard] stop")
if __name__ == "__main__":
main()
-602
View File
@@ -1,602 +0,0 @@
"""Esecuzione REALE su Deribit (testnet) con verifica post-ordine e fee reali.
Flusso per ogni ordine:
1. converte il notional (USD) in `amount` Deribit, arrotondato allo step del
contratto (BTC-PERPETUAL step $10, ETH-PERPETUAL step $1) e clampato al minimo;
2. piazza un market order REALE via Cerbero → Deribit private/buy|sell;
3. RIVERIFICA su Deribit: rilegge get_positions (la posizione esiste con la size
giusta?) e get_trade_history (ritrova il fill per order_id) — non si fida della
sola risposta dell'ordine;
4. estrae la FEE REALE dai trades[] del fill (per i perp inverse la fee e' nel
coin di settlement: BTC/ETH → la convertiamo anche in USD col prezzo di fill).
NB perp inverse Deribit: `amount` e la dimensione posizione sono in USD notional;
la fee dei trade e' denominata nel base-coin (BTC/ETH).
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from src.live.cerbero_client import CerberoClient
# Specifiche contratto (verificate da test.deribit.com/public/get_instrument).
# INVERSE (reversed): amount in USD, step in USD (es. BTC $10, ETH $1).
# LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001);
# il notional USD si converte col prezzo. Fee/settle in USDC.
_CONTRACT: dict[str, dict[str, Any]] = {
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5},
"ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05},
"BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC"},
"ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC"},
# lineari USDC per le gambe dei pairs (PairsExecutionClient, 2026-06-08)
"LTC_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.01, "settle": "USDC"},
"ADA_USDC-PERPETUAL": {"linear": True, "min": 0.2, "step": 0.2, "tick": 1e-05, "settle": "USDC"},
"SOL_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.001, "settle": "USDC"},
}
def contract_spec(instrument: str) -> dict[str, Any]:
return _CONTRACT.get(instrument, {"linear": False, "min": 1.0, "step": 1.0})
def register_contract(instrument: str, client) -> dict[str, Any]:
"""Recupera lo spec di uno strumento USDC da Deribit (get_instruments) e lo
registra in _CONTRACT. Fallback per strumenti pair non hardcodati; ritorna lo
spec (o il default se non trovato). Idempotente."""
if instrument in _CONTRACT:
return _CONTRACT[instrument]
try:
for i in client.get_instruments(currency="USDC", kind="future", limit=300):
if i.get("symbol") == instrument:
step = float(i["native"].get("min_trade_amount"))
_CONTRACT[instrument] = {"linear": True, "min": step, "step": step,
"tick": float(i.get("tick_size") or 0), "settle": "USDC"}
return _CONTRACT[instrument]
except Exception:
pass
return contract_spec(instrument)
def settlement_currency(instrument: str) -> str:
"""Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions
e per denominare la fee."""
spec = contract_spec(instrument)
if spec.get("settle"):
return spec["settle"]
return instrument.split("-")[0].split("_")[0]
def _quantize_step(value: float, step: float, mn: float) -> float:
"""Arrotonda `value` al multiplo di `step` SENZA artefatti float
(es. 72*0.001 = 0.07200000000000001 → Deribit 'Invalid params')."""
n_steps = round(value / step)
return float(max(n_steps * Decimal(str(step)), Decimal(str(mn))))
def quantize_price(instrument: str, price: float, side: str | None = None) -> float:
"""Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).
side (code-review 2026-06-11): per un limit di USCITA il rounding al tick
piu' vicino puo' mettere il resting OLTRE il livello sim → un tocco esatto
del livello non filla MAI il resting e il gate TP_PHANTOM classificherebbe
phantom un tocco genuino, sistematicamente. 'sell' = floor (mai sopra il
livello), 'buy' = ceil (mai sotto): il resting e' sempre raggiungibile
quando il sim dichiara il tocco (costo: <=1 tick di PnL vs sim)."""
import math
tick = contract_spec(instrument).get("tick")
if not tick or price <= 0:
return price
n = (math.floor(price / tick) if side == "sell"
else math.ceil(price / tick) if side == "buy"
else round(price / tick))
return float(n * Decimal(str(tick)))
def notional_to_amount(instrument: str, notional_usd: float,
price: float | None = None) -> float:
"""Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo.
Inverse: amount in USD (step USD). Lineari USDC: amount in base-coin (serve il
`price` per convertire). Ritorna 0.0 se sotto mezzo step (niente ordine)."""
spec = contract_spec(instrument)
step, mn = spec["step"], spec["min"]
if spec.get("linear"):
if not price:
return 0.0
units = notional_usd / price # base-coin richiesti
if units < step / 2:
return 0.0
return _quantize_step(units, step, mn)
if notional_usd < step / 2:
return 0.0
return _quantize_step(notional_usd, step, mn)
@dataclass
class Fill:
"""Esito verificato di un ordine reale."""
instrument: str
side: str # "buy" | "sell"
requested_notional: float # USD chiesti dalla strategia
amount: float # USD effettivi (arrotondati allo step)
fill_price: float | None # prezzo medio di esecuzione (da Deribit)
fee_coin: float # fee reale nel coin di settlement (BTC/ETH)
fee_usd: float # fee reale convertita in USD (fee_coin * fill_price)
order_id: str | None
order_state: str | None # "filled" atteso per market
verified: bool # posizione/trade riscontrati su Deribit
raw: dict[str, Any] = field(default_factory=dict)
notes: str = ""
# amount REALMENTE fillato (order.filled_amount / somma trades) — puo' essere
# MINORE del richiesto: un reduce-only cappato dal netting di conto (worker
# long e short sullo stesso strumento) viene ridotto in silenzio da Deribit.
# Audit 2026-06-11: close 0.105, fillato 0.078, bookato pieno perche' il
# ledger usava `amount`. I ledger devono usare QUESTO campo.
filled_amount: float = 0.0
def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill:
"""Combina il reduce-only e il residuo netting in UN esito per il chiamante:
filled = somma, prezzo = media pesata sui fill, fee sommate. verified se i
contratti riscontrati coprono il richiesto (la stessa soglia del chiamante)."""
fa = ro.filled_amount if ro.verified else 0.0
fb = net.filled_amount if net.verified else 0.0
tot = fa + fb
parts = [(amt, f.fill_price) for amt, f in ((fa, ro), (fb, net))
if amt > 0 and f.fill_price]
px = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)) if parts else None
step = contract_spec(ro.instrument).get("step", 0.001)
verified = tot >= requested - step / 2
return Fill(ro.instrument, ro.side, ro.requested_notional, requested, px,
ro.fee_coin + net.fee_coin, ro.fee_usd + net.fee_usd,
net.order_id or ro.order_id, net.order_state or ro.order_state,
verified, raw={"reduce_only": ro.raw, "net": net.raw},
notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), "
f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"),
filled_amount=tot)
def _avg_fill_price(order: dict, trades: list[dict]) -> float | None:
p = order.get("average_price")
if p:
return float(p)
# fallback: media pesata per amount dai trade
tot_amt = sum(float(t.get("amount", 0) or 0) for t in trades)
if tot_amt > 0:
return sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0)
for t in trades) / tot_amt
return None
@dataclass
class ExecutionClient:
"""Wrapper d'esecuzione reale sopra CerberoClient. ogni open/close ritorna un
Fill VERIFICATO (o verified=False con la ragione in notes)."""
client: CerberoClient = field(default_factory=CerberoClient)
verify_polls: int = 4 # tentativi di riverifica
verify_sleep: float = 0.6 # attesa fra i poll (s)
# Disaster-bracket on-book (2026-06-07): distanza % dello STOP_MARKET reduce-only
# piazzato a ogni REAL_OPEN come assicurazione per gli outage del feed/runner
# (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
# e' il MASSIMO dello strumento (informativo): l'esposizione reale la decide la SIZE
# dell'ordine, non una leva account. Percio' qui NON si passa leverage per-ordine.
# --- helper di verifica ---
def _position_size(self, instrument: str) -> float:
"""Size assoluta (USD) della posizione aperta sull'instrument, 0 se flat."""
cur = settlement_currency(instrument)
try:
for p in self.client.get_positions(currency=cur):
if p.get("instrument") == instrument:
return abs(float(p.get("size", 0) or 0))
except Exception:
pass
return 0.0
def _trade_by_order(self, instrument: str, order_id: str | None) -> dict | None:
"""Ritrova il fill nel trade history per order_id (fonte autorevole fee)."""
if not order_id:
return None
try:
for t in self.client.get_trade_history(limit=50, instrument_name=instrument):
if str(t.get("order_id")) == str(order_id):
return t
except Exception:
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:
try:
t = self.client.get_ticker(instrument)
return float(t.get("mark_price") or t.get("last_price") or 0) or None
except Exception:
return None
def amount_for(self, instrument: str, notional_usd: float) -> float:
"""Notional USD → amount Deribit (gestisce inverse/lineare, prezzo per i lineari)."""
spec = contract_spec(instrument)
price = self._mark_price(instrument) if spec.get("linear") else None
return notional_to_amount(instrument, notional_usd, price=price)
def _submit(self, instrument: str, side: str, amount: float,
requested_notional: float, reduce_only: bool,
label: str | None, order_type: str = "market",
price: float | None = None) -> Fill:
"""Ordine REALE (market o limit resting) + parsing del fill. Verifica
per-worker basata sul TRADE (order_id/trades), non sulla size netta — lo
strumento e' condiviso fra piu' worker e la posizione su Deribit e'
aggregata per conto. Per i limit la verifica e' l'ACCETTAZIONE in book
(state 'open', o 'filled' se crossa subito)."""
spec = contract_spec(instrument)
if amount <= 0:
return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0,
None, None, False, notes="notional sotto il minimo contratto")
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 []
order_id = order.get("order_id")
state = order.get("order_state")
fill_price = _avg_fill_price(order, trades)
# fee reale dai trade del fill (coin di settlement)
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
# riconciliazione su trade history per order_id (fonte autorevole) —
# inutile per un limit ancora in book senza fill
th = None
if order_type == "market" or trades:
th = self._trade_by_order(instrument, order_id)
if fee_coin == 0 and th and th.get("fee") is not None:
fee_coin = float(th["fee"])
if fill_price is None and th:
fill_price = float(th.get("price") or 0) or None
# lineari USDC: fee gia' in USDC; inverse: nel base-coin → * prezzo
fee_usd = fee_coin if spec.get("linear") else (
fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
# amount REALMENTE fillato: order.filled_amount e' la fonte autorevole
# (Deribit RIDUCE in silenzio un reduce-only che eccede il netto di conto);
# fallback: somma trades del fill, poi trade history
filled = float(order.get("filled_amount") or 0)
if not filled:
filled = sum(float(t.get("amount", 0) or 0) for t in trades)
if not filled and th:
filled = float(th.get("amount") or 0)
# VERIFICA: market = ordine filled E fill riscontrato (trades o history);
# limit = accettato in book ('open') o gia' eseguito ('filled');
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
if order_type == "market":
verified = (state == "filled") and (bool(trades) or th is not None)
elif order_type == "stop_market":
verified = state in ("untriggered", "open", "filled")
else:
verified = state in ("open", "filled")
notes = "" if verified else f"fill non verificato (state={state}, trades={len(trades)})"
if verified and order_type == "market" and filled < amount - 1e-12:
notes = (f"FILL PARZIALE: {filled} su {amount} richiesti "
"(reduce-only cappato dal netting di conto?)")
return Fill(instrument, side, requested_notional, amount, fill_price,
fee_coin, fee_usd, order_id, state, verified, raw=resp,
notes=notes, filled_amount=filled)
def open(self, instrument: str, side: str, notional_usd: float,
label: str | None = None) -> Fill:
"""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)
def close_amount(self, instrument: str, entry_side: str, amount: float,
label: str | None = None) -> Fill:
"""Chiude SOLO la quota del worker. Non usa close_position (flatterebbe
anche le quote degli altri worker sullo stesso strumento).
NETTING (v1.1.25, 2026-06-11): prima tenta il market reduce-only (la
sicurezza storica: un bug di stato filla 0 invece di aprire posizioni).
Su un conto a NETTING pero' il reduce-only viene CAPPATO o RESPINTO quando
un altro worker e' in direzione OPPOSTA sullo stesso strumento (audit
2026-06-11: 3 gambe pairs orfane + 1 close cappato) → il RESIDUO viene
rieseguito in MARKET PURO: muove il conto esattamente del delta del libro,
cioe' netta contro le quote opposte. La sicurezza persa sul residuo e'
coperta dal reconciler orario (reconcile_account.py, alert ACCOUNT_DRIFT).
Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate);
notes contiene 'netting' quando il fallback e' scattato."""
spec = contract_spec(instrument)
# quantizza difensivamente: il chiamante puo' passare un residuo
# (amount aperto fill parziale del TP) con artefatti float
amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0
opp = "sell" if entry_side == "buy" else "buy"
fill = self._submit(instrument, opp, amount, 0.0,
reduce_only=True, label=label)
residual = amount - fill.filled_amount
# check sulla POLVERE prima di quantizzare: _quantize_step clampa al lotto
# minimo, quindi un residuo da artefatto float (1e-17) diventerebbe un
# ordine nudo da un lotto intero (code-review 2026-06-11)
if residual < spec["step"] / 2:
return fill
residual = _quantize_step(residual, spec["step"], spec["min"])
# GUARD stato-stantio (code-review 2026-06-11): il residuo NON-reduce-only
# e' consentito solo fino al gap (conto reale libri degli ALTRI worker
# orfani) nella direzione della chiusura. Se la nostra quota non esiste
# piu' sul conto (disaster-SL scattato in outage, flatten manuale, stato
# stantio al resume) il gap e' ~0 → NIENTE ordine nudo: si ritorna il
# parziale onesto (il chiamante alza REAL_CLOSE_PARTIAL). Se il guard non
# e' calcolabile (rete) si resta FAIL-SAFE: meglio un orfano tracciato
# che una posizione nuda non tracciata.
allowed = self._net_close_allowance(instrument, opp, label)
if allowed is None or allowed < spec["step"] / 2:
fill.notes = (fill.notes + " | " if fill.notes else "") + (
f"netting NEGATO: residuo {residual} ma gap conto-libri "
f"{'non calcolabile' if allowed is None else round(allowed, 6)} "
"(stato stantio? quota gia' chiusa?)")
return fill
if allowed < residual:
residual = _quantize_step(allowed, spec["step"], spec["min"])
net = self._submit(instrument, opp, residual, 0.0, reduce_only=False,
label=f"{label}|net" if label else "net")
return _merge_close_fills(fill, net, amount)
def _net_close_allowance(self, instrument: str, close_side: str,
self_worker: str | None) -> float | None:
"""Quanto residuo non-reduce-only e' GIUSTIFICATO dai libri: gap firmato
fra il conto reale e (libri degli altri worker + orfani registrati),
proiettato nella direzione della chiusura. None = non calcolabile."""
try:
from src.live.books import real_books, account_net
books, orphans = real_books(exclude_worker=self_worker)
target = books.get(instrument, 0.0) + orphans.get(instrument, 0.0)
pos = account_net(self.client).get(instrument, 0.0)
gap = pos - target # quota nostra ancora sul conto (firmata)
return max(0.0, gap if close_side == "sell" else -gap)
except Exception:
return None
def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill:
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata):
il fill reale replica l'assunzione intrabar del backtest (exit AL livello)
invece del market-on-poll post-rimbalzo (+235 bps misurati il 2026-06-04).
Copre la SOLA quota del worker (stesso `amount` dell'apertura) — lo
strumento e' condiviso fra piu' worker. NB: se il prezzo e' gia' oltre il
TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
valle (resting_fills) lo gestisce come un fill normale."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, tp_price, side=opp)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/tp non validi")
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="limit", price=px)
def place_disaster_sl(self, instrument: str, entry_side: str, amount: float,
stop_price: float, label: str | None = None) -> Fill:
"""STOP_MARKET reduce-only LONTANO (disaster bracket ~-30%): assicurazione
on-book per gli outage — in operativita' normale non scatta mai (lo SL
della strategia esce molto prima, market-on-poll) -> 0 costo Sharpe.
Trigger sul MARK price; copre la SOLA quota del worker. Nei crash il fill
e' al gap, non al livello: cappa la coda, non la elimina (exit-lab).
NB: il set_stop_loss di cerbero_client e' un private/edit Deribit (solo
ordini APERTI) -> inutilizzabile su una posizione gia' fillata; il
bracket si piazza come ordine trigger autonomo."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, stop_price)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/stop non validi")
return self._submit(instrument, opp, amount, 0.0, reduce_only=True,
label=label, order_type="stop_market", price=px)
def cancel_order(self, order_id: str) -> dict:
"""Cancella un ordine resting. {'state': 'cancelled'} su successo;
'error' se l'ordine non e' piu' open (es. gia' fillato) — NON fatale:
il chiamante riconcilia i fill via resting_fills (trade history)."""
if not order_id:
return {"state": "error", "error": "no order_id"}
try:
return self.client.cancel_order(order_id)
except Exception as exc:
return {"state": "error", "error": str(exc)}
def resting_fills(self, instrument: str,
order_id: str) -> tuple[float, float | None, float]:
"""Fill (anche parziali) di un ordine resting, riconciliati dal trade
history per order_id (fonte autorevole, come per i market). Ritorna
(amount_fillato, prezzo_medio, fee_usd)."""
if not order_id:
return 0.0, None, 0.0
spec = contract_spec(instrument)
try:
trades = [t for t in self.client.get_trade_history(
limit=100, instrument_name=instrument)
if str(t.get("order_id")) == str(order_id)]
except Exception:
return 0.0, None, 0.0
amt = sum(float(t.get("amount", 0) or 0) for t in trades)
if amt <= 0:
return 0.0, None, 0.0
avg = sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0)
for t in trades) / amt
fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades)
fee_usd = fee_coin if spec.get("linear") else (fee_coin * avg if avg else 0.0)
return amt, avg or None, fee_usd
def close(self, instrument: str, label: str | None = None) -> Fill:
"""Chiude a mercato la posizione e riverifica che il conto sia flat,
leggendo la fee di chiusura dal trade history."""
side = "close"
resp = self.client.close_position(instrument)
if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp:
return Fill(instrument, side, 0.0, 0.0, None, 0.0, 0.0, None, "error",
False, raw=resp if isinstance(resp, dict) else {},
notes=f"close error: {resp}")
order_id = resp.get("order_id")
# fee/prezzo di chiusura dal trade history (close_position non li ritorna)
th = self._trade_by_order(instrument, order_id)
fee_coin = float(th["fee"]) if th and th.get("fee") is not None else 0.0
fill_price = float(th.get("price")) if th and th.get("price") else None
if contract_spec(instrument).get("linear"):
fee_usd = fee_coin
else:
fee_usd = fee_coin * fill_price if (fee_coin and fill_price) else 0.0
# verifica: la posizione deve essere tornata flat
pos = 1.0
for _ in range(self.verify_polls):
pos = self._position_size(instrument)
if pos == 0:
break
time.sleep(self.verify_sleep)
verified = pos == 0
return Fill(instrument, side, 0.0, 0.0, fill_price, fee_coin, fee_usd,
order_id, resp.get("state"), verified, raw=resp,
notes="" if verified else f"posizione non flat dopo close (pos={pos})")
@dataclass
class PairFill:
"""Esito verificato di un'apertura/chiusura a 2 GAMBE."""
verified: bool # entrambe le gambe eseguite e verificate
leg_a: Fill
leg_b: Fill
unwound: bool = False # true se una gamba e' fallita e l'altra e' stata richiusa
notes: str = ""
@dataclass
class PairsExecutionClient:
"""Esecuzione REALE a 2 gambe (shadow) per i pairs market-neutral su Deribit.
Compone un ExecutionClient single-leg: apre/chiude le due gambe (long A / short B
o viceversa) come market reduce_only-aware, con gestione del LEG-RISK:
- open_pair: piazza entrambe; se UNA sola filla -> UNWIND (richiude la fillata
reduce-only) per non restare con esposizione direzionale netta -> verified=False.
- close_pair: chiude entrambe reduce-only (market); ritorna fee e prezzi reali.
Strumenti = lineari USDC (payoff lineare == matematica del backtest a 2 gambe; fee
in USDC). amount per gamba arrotondato allo step del rispettivo strumento (doppio
arrotondamento: piccolo sbilanciamento di notional inevitabile, riportato).
"""
leg: "ExecutionClient" = field(default_factory=ExecutionClient)
def __post_init__(self):
# registra gli spec USDC degli strumenti pair non hardcodati (LTC/ADA/SOL ci sono;
# questo copre eventuali coppie future)
self.client = self.leg.client
def ensure_specs(self, *instruments: str):
for inst in instruments:
register_contract(inst, self.client)
def open_pair(self, inst_a: str, inst_b: str, direction: int,
notional_usd: float, label: str | None = None) -> PairFill:
"""direction +1 = long A / short B; -1 = short A / long B. notional uguale per gamba."""
self.ensure_specs(inst_a, inst_b)
side_a = "buy" if direction == 1 else "sell"
side_b = "sell" if direction == 1 else "buy"
fa = self.leg.open(inst_a, side_a, notional_usd, label=label)
fb = self.leg.open(inst_b, side_b, notional_usd, label=label)
if fa.verified and fb.verified:
return PairFill(True, fa, fb)
# LEG-RISK: una sola gamba (o nessuna) verificata -> unwind la fillata
unwound = False
for f, inst in ((fa, inst_a), (fb, inst_b)):
if f.verified and f.amount > 0:
# unwind del FILLATO, non del richiesto: col fallback netting un
# amount sovrastimato non viene piu' cappato in silenzio dal
# reduce-only — chiuderebbe quota altrui (code-review 2026-06-11)
self.leg.close_amount(inst, f.side, f.filled_amount, label=label)
unwound = True
return PairFill(False, fa, fb, unwound=unwound,
notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}")
def close_pair(self, inst_a: str, inst_b: str, side_a: str, side_b: str,
amount_a: float, amount_b: float, label: str | None = None) -> PairFill:
"""Chiude entrambe le gambe a mercato (reduce-only del lato opposto all'entrata).
Ritorna PairFill con i Fill di chiusura (fee/prezzi reali). verified = entrambe chiuse."""
ca = self.leg.close_amount(inst_a, side_a, amount_a, label=label)
cb = self.leg.close_amount(inst_b, side_b, amount_b, label=label)
return PairFill(ca.verified and cb.verified, ca, cb,
notes="" if (ca.verified and cb.verified)
else f"close parziale (a={ca.verified} b={cb.verified})")
-157
View File
@@ -1,157 +0,0 @@
"""GridWorker — Price Ladder (griglia) live SIM/PAPER, shadow-stage 1.
Worker live per la strategia Price Ladder (griglia geometrica con regime-gate + SL/TP,
config vincente del branch price_ladder_research). STAGE 1 = SIM/PAPER: gira sul feed LIVE
Deribit (stessi dati di decisione degli altri worker) e contabilizza l'equity mark-to-market
col MOTORE CANONICO `grid_mtm` (parita' col backtest per costruzione), MA non piazza ordini
reali. Accumula un track record paper per validare live-vs-backtest prima dello shadow reale.
NON esegue ordini: l'esecuzione reale (griglia di LIMIT resting su Deribit, gestione fill
parziali/episodi) e' lo STAGE 2, dietro testnet + autorizzazione esplicita (soldi veri,
siamo su mainnet). Per costruzione il runner avvia ordini reali solo per kind in
('single','ml'); kind='grid' resta sim.
Stato persistente (status.json): capital, peak, max_dd, n_trades, last_ts -> resume al restart.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from scripts.analysis.grid_game_gate import grid_mtm
def _regime_mask(df: pd.DataFrame, ema_n: int, trend_max: float) -> np.ndarray:
"""Mask CAUSALE 'range-bound' allineata a df (== ladder_search.regime_mask, ma su df live)."""
c = df["close"].to_numpy(float)
h = df["high"].to_numpy(float); l = df["low"].to_numpy(float)
ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy()
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
atr = pd.Series(tr).rolling(14).mean().to_numpy()
with np.errstate(invalid="ignore", divide="ignore"):
dist = np.abs(c - ema) / np.where(atr == 0, np.nan, atr)
m = dist < trend_max
m[~np.isfinite(dist)] = False
return m
class GridWorker:
KIND = "grid"
def __init__(self, sid: str, asset: str, params: dict, capital: float,
work_dir: Path, leverage: float = 3.0, position_size: float = 0.15,
fee_side: float = 0.0005, notifier=None, hist: pd.DataFrame | None = None):
self.sid = sid
self.asset = asset
self.p = dict(params) # tf,range_down,range_up,levels,sl_buf,tp_buf,max_bars,regime,trend_max
self.leverage = leverage
self.position_size = position_size
self.fee_side = fee_side
self.notifier = notifier
self.initial_capital = capital
self.capital = capital
self.peak = capital
self.max_dd = 0.0
self.n_trades = 0
self.last_ts = ""
# base_norm = valore dell'equity-norm (cumulata da inizio storia) al DEPLOY: la
# capital forward = initial * eq[-1]/base_norm -> parte da `initial` e segue il
# ritorno della griglia DA QUEL MOMENTO (start FISSO: niente salti da finestra mobile).
self.base_norm = None
# bootstrap STORIA FULL (start fisso, come SH01): il feed live e' una finestra mobile,
# ma normalizzando su una serie a start fisso l'equity forward e' stabile.
if hist is None:
try:
from src.data.downloader import load_data
hist = load_data(asset, self.p.get("tf", "1h"))
except Exception:
hist = None
self.hist = hist
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.in_position = False # compat dashboard (la griglia non ha una posizione singola)
self._load_state()
def _merge(self, live_df: pd.DataFrame) -> pd.DataFrame:
"""Storia bootstrap + feed live, dedup su timestamp (il live prevale), start FISSO."""
if self.hist is None or len(self.hist) == 0:
return live_df
cols = ["timestamp", "open", "high", "low", "close", "volume"]
h = self.hist[[c for c in cols if c in self.hist.columns]]
l = live_df[[c for c in cols if c in live_df.columns]]
m = pd.concat([h, l], ignore_index=True)
m = m.drop_duplicates(subset="timestamp", keep="last").sort_values("timestamp")
return m.reset_index(drop=True)
def _load_state(self):
if not self.status_path.exists():
self._log("INIT", {"capital": round(self.capital, 2), "sid": self.sid})
return
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.initial_capital)
self.peak = s.get("peak", self.capital)
self.max_dd = s.get("max_dd", 0.0)
self.n_trades = s.get("n_trades", 0)
self.last_ts = s.get("last_ts", "")
self.base_norm = s.get("base_norm")
self._log("RESUME", {"capital": round(self.capital, 2), "n_trades": self.n_trades,
"base_norm": self.base_norm})
def _save_state(self):
self.status_path.write_text(json.dumps({
"sid": self.sid, "kind": self.KIND, "asset": self.asset,
"capital": round(self.capital, 4), "peak": round(self.peak, 4),
"max_dd": round(self.max_dd, 4), "n_trades": self.n_trades,
"base_norm": self.base_norm, "in_position": self.in_position, "params": self.p,
"last_ts": self.last_ts, "ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
def _log(self, event: str, extra: dict):
row = {"ts": datetime.now(timezone.utc).isoformat(), "sid": getattr(self, "sid", "?"),
"event": event, **extra}
try:
with open(self.work_dir / "trades.jsonl", "a") as f:
f.write(json.dumps(row) + "\n")
except Exception:
pass
def tick(self, df: pd.DataFrame):
"""df = OHLCV live (finestra mobile) fino ad ora. Merge con la storia bootstrap
(start FISSO), ricomputa la griglia col motore canonico, e mappa il capitale forward:
capital = initial * eq[-1]/base_norm (parte da `initial` al deploy, segue la griglia
da li' in poi). SIM only (nessun ordine reale)."""
if df is None or len(df) < 40:
return
full = self._merge(df)
p = self.p
regime = p.get("regime", "none")
mask = (_regime_mask(full, p.get("ema_n", 200), p.get("trend_max", 2.0))
if regime == "range" else None)
eqd, st = grid_mtm(
self.asset, tf=p["tf"], range_down=p["range_down"], range_up=p["range_up"],
levels=p["levels"], sl_buf=p["sl_buf"], tp_buf=p["tp_buf"], max_bars=p["max_bars"],
pos=self.position_size, lev=self.leverage, fee_side=self.fee_side,
flat_skip=True, deploy_mask=mask, df=full)
if eqd is None or len(eqd) == 0:
return
cur = float(eqd.iloc[-1])
if self.base_norm is None or self.base_norm <= 0:
self.base_norm = cur # baseline al primo tick (deploy)
self.capital = max(self.initial_capital * cur / self.base_norm, 0.0)
self.peak = max(self.peak, self.capital)
if self.peak > 0:
self.max_dd = max(self.max_dd, (self.peak - self.capital) / self.peak)
self.n_trades = int(st.get("trades", self.n_trades))
self.last_ts = str(full.iloc[-1].get("timestamp", ""))
self._save_state()
self._log("GRID_MTM", {"capital": round(self.capital, 2), "n_trades": self.n_trades,
"win": st.get("win"), "stops": st.get("stops"),
"pnl_source": "sim"})
return self.capital
-342
View File
@@ -1,342 +0,0 @@
"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo."""
from __future__ import annotations
import time
import yaml
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import send_telegram
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = PROJECT_ROOT / "data" / "paper_trades"
RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"}
# Convenzione Deribit (verificata via Cerbero, 2026-05-29):
# - BTC/ETH = perpetui INVERSE (margine coin): "<COIN>-PERPETUAL"
# - altcoin = perpetui LINEARI USDC (margine USDC): "<COIN>_USDC-PERPETUAL", storia dal 2022
# Trappola: "LTC-PERPETUAL"/"ADA-PERPETUAL" = 0 candele; "SOL-PERPETUAL" = contratto vecchio
# con dati sbagliati. Per gli alt usare SEMPRE la forma _USDC-PERPETUAL.
INSTRUMENT_MAP = {
"BTC": "BTC-PERPETUAL",
"ETH": "ETH-PERPETUAL",
"SOL": "SOL_USDC-PERPETUAL",
"LTC": "LTC_USDC-PERPETUAL",
"ADA": "ADA_USDC-PERPETUAL",
"XRP": "XRP_USDC-PERPETUAL",
"BNB": "BNB_USDC-PERPETUAL",
"DOGE": "DOGE_USDC-PERPETUAL",
}
class MLWorkerWrapper:
"""Wrapper speciale per ML01 che usa SignalEngine con training."""
def __init__(self, worker: StrategyWorker, config: dict):
self.worker = worker
self.engine = SignalEngine(
bb_w=config.get("params", {}).get("bb_window", 14),
sq_thr=config.get("params", {}).get("sq_threshold", 0.8),
ml_thr=config.get("params", {}).get("ml_threshold", 0.70),
)
self.trained = False
self.last_train: datetime | None = None
self.retrain_hours = config.get("retrain_hours", 24)
def needs_training(self) -> bool:
if not self.trained:
return True
if self.last_train is None:
return True
elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds()
return elapsed > self.retrain_hours * 3600
def train(self, df: pd.DataFrame, hold: int = 3):
result = self.engine.train(df, lookahead=hold)
if "error" not in result:
self.trained = True
self.last_train = datetime.now(timezone.utc)
print(f" [{self.worker.worker_id}] TRAIN OK: {result}")
else:
print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}")
def tick(self, df: pd.DataFrame):
if not self.trained:
return
worker = self.worker
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
if worker.in_position:
if current_ts > worker.last_bar_ts:
worker.bars_held += 1
worker.last_bar_ts = current_ts
if worker.bars_held >= worker.hold_bars:
worker._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction
if pnl_pct <= -0.02:
worker._close_position(current_price, "stop_loss")
worker._save_state()
return
signal = self.engine.check_signal(df)
if signal:
from src.strategies.base import Signal
direction = 1 if signal["direction"] == "buy" else -1
sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price)
worker._open_position(sig, current_price)
worker.last_bar_ts = current_ts
worker._save_state()
def load_config(path: Path) -> dict:
with open(path) as f:
return yaml.safe_load(f)
def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]:
"""Crea worker da config YAML."""
defaults = config.get("defaults", {})
regular_workers: list[StrategyWorker] = []
ml_workers: list[MLWorkerWrapper] = []
for entry in config.get("strategies", []):
if not entry.get("enabled", True):
continue
name = entry["name"]
asset = entry["asset"]
tf = entry["tf"]
capital = entry.get("capital", defaults.get("capital", 1000))
pos_size = entry.get("position_size", defaults.get("position_size", 0.15))
leverage = entry.get("leverage", defaults.get("leverage", 3))
hold = entry.get("hold_bars", defaults.get("hold_bars", 3))
params = entry.get("params", {})
strategy = load_strategy(name)
worker = StrategyWorker(
strategy=strategy, asset=asset, tf=tf,
capital=capital, position_size=pos_size,
leverage=leverage, hold_bars=hold,
params=params, data_dir=DATA_DIR,
)
if name == "ML01_squeeze_gbm":
ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry})
ml_workers.append(ml_wrapper)
else:
regular_workers.append(worker)
return regular_workers, ml_workers
def build_pairs_workers(config: dict) -> list[PairsWorker]:
"""Crea i PairsWorker (2 gambe) dalla sezione `pairs:` dello YAML."""
defaults = config.get("defaults", {})
workers: list[PairsWorker] = []
for entry in config.get("pairs", []):
if not entry.get("enabled", True):
continue
workers.append(PairsWorker(
asset_a=entry["a"], asset_b=entry["b"], tf=entry.get("tf", "1h"),
params=entry.get("params", {}),
capital=entry.get("capital", defaults.get("capital", 1000)),
position_size=entry.get("position_size", defaults.get("position_size", 0.15)),
leverage=entry.get("leverage", defaults.get("leverage", 3)),
fee_rt=entry.get("fee_rt", 0.001),
name=entry.get("name", "PR01_pairs_reversion"),
data_dir=DATA_DIR,
))
return workers
def run():
config_path = PROJECT_ROOT / "strategies.yml"
if not config_path.exists():
print(f"ERRORE: {config_path} non trovato")
return
config = load_config(config_path)
defaults = config.get("defaults", {})
poll_seconds = defaults.get("poll_seconds", 60)
lookback_days = 60
train_lookback_days = 365
regular_workers, ml_workers = build_workers(config)
pairs_workers = build_pairs_workers(config)
all_worker_count = len(regular_workers) + len(ml_workers) + len(pairs_workers)
if all_worker_count == 0:
print("Nessuna strategia abilitata in strategies.yml")
return
client = CerberoClient()
print("=" * 70)
print(f" MULTI-STRATEGY PAPER TRADER")
print(f" Strategie attive: {all_worker_count}")
print(f" Poll: ogni {poll_seconds}s")
print(f" Data dir: {DATA_DIR}")
print("=" * 70)
for w in regular_workers:
print(f"{w.status_summary}")
for mw in ml_workers:
print(f"{mw.worker.status_summary} [ML]")
for pw in pairs_workers:
print(f"{pw.status_summary} [PAIRS]")
send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie")
# Raccogli asset/tf unici per fetch raggruppato
def _get_data_keys() -> set[tuple[str, str]]:
keys = set()
for w in regular_workers:
keys.add((w.asset, w.tf))
for mw in ml_workers:
keys.add((mw.worker.asset, mw.worker.tf))
for pw in pairs_workers: # entrambe le gambe del pair
keys.add((pw.asset_a, pw.tf))
keys.add((pw.asset_b, pw.tf))
return keys
# Training iniziale ML
for mw in ml_workers:
asset = mw.worker.asset
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(mw.worker.tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=train_lookback_days)
candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution)
if candles:
df_train = pd.DataFrame(candles)
df_train["timestamp"] = df_train["timestamp"].astype("int64")
df_train = df_train.sort_values("timestamp").reset_index(drop=True)
mw.train(df_train, hold=mw.worker.hold_bars)
while True:
try:
data_keys = _get_data_keys()
candle_cache: dict[tuple[str, str], pd.DataFrame] = {}
for asset, tf in data_keys:
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
resolution = RESOLUTION_MAP.get(tf, "15")
end = datetime.now(timezone.utc)
start = end - timedelta(days=lookback_days)
candles = client.get_historical(
instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), resolution,
)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
candle_cache[(asset, tf)] = df
# Fetch 1h live per strategie multi-timeframe (es. MT01):
# il trend va preso da Cerbero, non dal parquet statico (che resta indietro).
htf_cache: dict[str, pd.DataFrame] = {}
mtf_assets = {w.asset for w in regular_workers if w.strategy.name.startswith("MT01")}
for asset in mtf_assets:
instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
end = datetime.now(timezone.utc)
start = end - timedelta(days=lookback_days)
try:
candles_1h = client.get_historical(
instrument, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "60",
)
if candles_1h:
df1h = pd.DataFrame(candles_1h)
df1h["timestamp"] = df1h["timestamp"].astype("int64")
htf_cache[asset] = df1h.sort_values("timestamp").reset_index(drop=True)
except Exception as e:
print(f" [1h fetch {asset}] ERRORE: {e}")
# Tick regular workers
for w in regular_workers:
key = (w.asset, w.tf)
if key in candle_cache:
try:
w.tick(candle_cache[key], df_1h=htf_cache.get(w.asset))
except Exception as e:
print(f" [{w.worker_id}] ERRORE: {e}")
# Tick ML workers
for mw in ml_workers:
key = (mw.worker.asset, mw.worker.tf)
if key not in candle_cache:
continue
if mw.needs_training():
mw.train(candle_cache[key], hold=mw.worker.hold_bars)
try:
mw.tick(candle_cache[key])
except Exception as e:
print(f" [{mw.worker.worker_id}] ERRORE: {e}")
# Tick pairs workers (2 gambe)
for pw in pairs_workers:
ka, kb = (pw.asset_a, pw.tf), (pw.asset_b, pw.tf)
if ka in candle_cache and kb in candle_cache:
try:
pw.tick(candle_cache[ka], candle_cache[kb])
except Exception as e:
print(f" [{pw.worker_id}] ERRORE: {e}")
# Status periodico
now = datetime.now(timezone.utc)
if now.minute == 0 and now.second < poll_seconds:
lines = [f"📊 Status {now.strftime('%H:%M')} UTC"]
for w in regular_workers:
lines.append(f" {w.status_summary}")
for mw in ml_workers:
lines.append(f" {mw.worker.status_summary} [ML]")
for pw in pairs_workers:
lines.append(f" {pw.status_summary} [PAIRS]")
send_telegram("\n".join(lines))
except KeyboardInterrupt:
print("\nShutdown...")
for w in regular_workers:
if w.in_position:
df = candle_cache.get((w.asset, w.tf))
if df is not None and not df.empty:
w._close_position(float(df["close"].iloc[-1]), "shutdown")
w._save_state()
for mw in ml_workers:
if mw.worker.in_position:
df = candle_cache.get((mw.worker.asset, mw.worker.tf))
if df is not None and not df.empty:
mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown")
mw.worker._save_state()
for pw in pairs_workers: # salva stato; non forzo la chiusura a 2 gambe
pw._save_state()
send_telegram("🛑 Multi-Strategy arrestato")
break
except Exception as e:
print(f" ERRORE GLOBALE: {e}")
import traceback
traceback.print_exc()
time.sleep(poll_seconds)
if __name__ == "__main__":
run()
-426
View File
@@ -1,426 +0,0 @@
"""PairsWorker — paper trading a 2 GAMBE per la famiglia PR01 (spread reversion).
Market-neutral: long asset A / short asset B (o viceversa) sullo z-score del log-ratio.
Distinto dallo StrategyWorker single-leg: gestisce due strumenti, due prezzi di
ingresso, e conta le fee su ENTRAMBE le gambe (2*fee_rt*lev = 0.20% RT/coppia con
fee_rt=0.001). Semantica identica al backtest scripts/analysis/pairs_research.pairs_sim:
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
ENTRY a close[i]: z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio
EXIT: |z| <= z_exit (rientro) oppure time-limit max_bars
filtro candele sporche: salta l'ingresso se |dr[i]| > jump_max
PnL = (retA - retB) * direction * lev - 2*fee_rt*lev (notional uguale per gamba)
Stato persistente (resume al restart) e log come StrategyWorker.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.live.telegram_notifier import notify_event
class PairsWorker:
def __init__(
self,
asset_a: str,
asset_b: str,
tf: str,
params: dict | None = None,
capital: float = 1000.0,
position_size: float = 0.15,
leverage: float = 3.0,
fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x
name: str = "PR01_pairs_reversion",
data_dir: Path = Path("data/paper_trades"),
executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe
exec_instruments: dict | None = None, # {asset: instrument USDC}
real_truth: bool = False,
):
self.asset_a = asset_a
self.asset_b = asset_b
self.tf = tf
self.name = name
p = params or {}
self.n = int(p.get("n", 50))
self.z_in = float(p.get("z_in", 2.0))
self.z_exit = float(p.get("z_exit", 0.75))
self.max_bars = int(p.get("max_bars", 72))
self.jump_max = float(p.get("jump_max", 0.08))
# flat-skip (timeframe sub-orari, es. 15m): non entrare/uscire su candele flat
# (O=H=L=C, prezzo stale/liquidita' zero -> fill non eseguibile). LIVE-REALIZABLE:
# l'uscita arma exit_ready e si esegue alla prima barra PULITA. Parita' col backtest
# pairs_research.pairs_sim_flat(flat_skip=True). Default off = comportamento 1h storico.
self.flat_skip = bool(p.get("flat_skip", False))
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{asset_a}_{asset_b}__{tf}"
self.work_dir = data_dir / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.trades_path = self.work_dir / "trades.jsonl"
self.status_path = self.work_dir / "status.json"
self.capital = capital
self.in_position = False
self.direction = 0 # +1 long ratio (long A/short B), -1 short ratio
self.entry_a = 0.0
self.entry_b = 0.0
self.entry_z = 0.0
self.entry_time = ""
self.bars_held = 0
self.exit_ready = False # flat-skip: condizione di uscita armata, attende barra pulita
self.total_trades = 0
self.total_wins = 0
self.last_bar_ts = 0
self.started_at = datetime.now(timezone.utc).isoformat()
# --- esecuzione REALE shadow a 2 gambe (sim resta la verita' che guida) ---
self.executor = executor
self.exec_instruments = exec_instruments or {}
self.inst_a = self.exec_instruments.get(asset_a)
self.inst_b = self.exec_instruments.get(asset_b)
self.execution_enabled = bool(executor and self.inst_a and self.inst_b)
# REAL-TRUTH (2026-06-10): come StrategyWorker — `capital` aggiornato dal
# PnL dei fill reali (2 gambe, fee reali); sim solo diagnostica nel log.
self.real_truth = bool(real_truth and self.execution_enabled)
self.real_capital = capital
self.real_in_position = False
self.real_dir = 0
self.real_side_a = "" # lato della gamba A all'apertura ("buy"/"sell")
self.real_side_b = ""
self.real_amount_a = 0.0 # amount eseguito per gamba (base-coin)
self.real_amount_b = 0.0
self.real_entry_a = 0.0 # prezzo di fill per gamba
self.real_entry_b = 0.0
self.real_notional_a = 0.0 # USD effettivi per gamba
self.real_notional_b = 0.0
self.real_entry_fee = 0.0
self.real_trades = 0
self.real_first_notified = False
self.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite)
self._load_state()
self._save_state()
# ---------------- persistenza ----------------
def _load_state(self):
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "pair": f"{self.asset_a}/{self.asset_b}",
"tf": self.tf, "params": {"n": self.n, "z_in": self.z_in,
"z_exit": self.z_exit, "max_bars": self.max_bars}})
return
with open(self.status_path) as f:
s = json.load(f)
self.capital = s.get("capital", self.initial_capital)
self.in_position = s.get("in_position", False)
self.direction = s.get("direction", 0)
self.entry_a = s.get("entry_a", 0.0)
self.entry_b = s.get("entry_b", 0.0)
self.entry_z = s.get("entry_z", 0.0)
self.entry_time = s.get("entry_time", "")
self.bars_held = s.get("bars_held", 0)
self.exit_ready = s.get("exit_ready", False)
self.total_trades = s.get("total_trades", 0)
self.total_wins = s.get("total_wins", 0)
self.last_bar_ts = s.get("last_bar_ts", 0)
self.started_at = s.get("started_at", self.started_at)
self.real_capital = s.get("real_capital", self.initial_capital)
self.real_in_position = s.get("real_in_position", False)
self.real_dir = s.get("real_dir", 0)
self.real_side_a = s.get("real_side_a", "")
self.real_side_b = s.get("real_side_b", "")
self.real_amount_a = s.get("real_amount_a", 0.0)
self.real_amount_b = s.get("real_amount_b", 0.0)
self.real_entry_a = s.get("real_entry_a", 0.0)
self.real_entry_b = s.get("real_entry_b", 0.0)
self.real_notional_a = s.get("real_notional_a", 0.0)
self.real_notional_b = s.get("real_notional_b", 0.0)
self.real_entry_fee = s.get("real_entry_fee", 0.0)
self.real_trades = s.get("real_trades", 0)
self.real_first_notified = s.get("real_first_notified", False)
self.orphan_legs = s.get("orphan_legs", [])
self._log("RESUME", {"capital": round(self.capital, 2),
"total_trades": self.total_trades, "in_position": self.in_position,
"real_capital": round(self.real_capital, 2),
"real_in_position": self.real_in_position})
def _save_state(self):
state = {
"capital": round(self.capital, 2), "in_position": self.in_position,
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
"bars_held": self.bars_held, "exit_ready": self.exit_ready,
"total_trades": self.total_trades,
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
"real_capital": round(self.real_capital, 4), "real_in_position": self.real_in_position,
"real_dir": self.real_dir, "real_side_a": self.real_side_a, "real_side_b": self.real_side_b,
"real_amount_a": self.real_amount_a, "real_amount_b": self.real_amount_b,
"real_entry_a": self.real_entry_a, "real_entry_b": self.real_entry_b,
"real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b,
"real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades,
"real_first_notified": self.real_first_notified,
"orphan_legs": self.orphan_legs,
}
with open(self.status_path, "w") as f:
json.dump(state, f, indent=2)
def _log(self, event: str, data: dict | None = None):
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
"event": event, **(data or {})}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
def _notify(self, event: str, data: dict | None = None):
notify_event(event, {"worker": self.worker_id, **(data or {})})
# ---------------- segnale ----------------
def _zscore(self, ca: np.ndarray, cb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
r = np.log(ca / cb)
ma = pd.Series(r).rolling(self.n).mean().values
sd = pd.Series(r).rolling(self.n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd)
dr = np.abs(np.diff(r, prepend=r[0]))
return z, dr
# ---------------- trading ----------------
def _open(self, d: int, ca: float, cb: float, z: float):
self.in_position = True
self.direction = d
self.entry_a, self.entry_b, self.entry_z = ca, cb, z
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
self.exit_ready = False
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
"long_leg": self.asset_a if d == 1 else self.asset_b,
"short_leg": self.asset_b if d == 1 else self.asset_a,
"entry_a": round(ca, 4), "entry_b": round(cb, 4), "z": round(z, 3),
"capital": round(self.capital, 2)}
self._log("OPEN", data); self._notify("OPENED", data)
if self.execution_enabled:
self._real_open_pair(d, ca, cb)
def _real_open_pair(self, d: int, sim_a: float, sim_b: float):
"""Apertura REALE shadow a 2 gambe (long A/short B se d=1). Notional uguale per
gamba = capital*pos*lev. Logga slippage e fee reali; gestisce il leg-fail."""
notional = self.capital * self.position_size * self.leverage
pf = self.executor.open_pair(self.inst_a, self.inst_b, d, notional, label=self.worker_id)
data = {"dir": d, "inst_a": self.inst_a, "inst_b": self.inst_b,
"notional_leg": round(notional, 2),
"fill_a": pf.leg_a.fill_price, "fill_b": pf.leg_b.fill_price,
"fee_usd": round(pf.leg_a.fee_usd + pf.leg_b.fee_usd, 5),
"verified": pf.verified}
if pf.verified:
self.real_in_position = True
self.real_dir = d
self.real_side_a, self.real_side_b = pf.leg_a.side, pf.leg_b.side
# amount FILLATO, non richiesto (coerente con strategy_worker, 2026-06-11)
self.real_amount_a = pf.leg_a.filled_amount or pf.leg_a.amount
self.real_amount_b = pf.leg_b.filled_amount or pf.leg_b.amount
self.real_entry_a = pf.leg_a.fill_price or sim_a
self.real_entry_b = pf.leg_b.fill_price or sim_b
self.real_notional_a = pf.leg_a.amount * self.real_entry_a
self.real_notional_b = pf.leg_b.amount * self.real_entry_b
self.real_entry_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd
self._log("REAL_OPEN_PAIR", data)
if not self.real_first_notified:
self._notify("REAL_EXEC_LIVE", data); self.real_first_notified = True
else:
self._log("REAL_OPEN_FAIL", {**data, "note": pf.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes})
self._save_state() # persisti subito il ledger reale (resume-safe sui crash)
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE shadow: richiude entrambe le gambe (netting-aware),
riconcilia PnL reale per-gamba e fee, aggiorna il ledger reale parallelo.
Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno
chiuso per intero con fill verificato — con una gamba orfana il "PnL dello
spread" non esiste e real-truth ricade sul sim DICHIARATO."""
if not self.real_in_position:
return None, False
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
self.real_side_b, self.real_amount_a, self.real_amount_b,
label=self.worker_id)
# VERITA' PER-GAMBA (audit 2026-06-11): una gamba puo' essere RESPINTA dal
# netting di conto (reduce-only nel verso sbagliato quando un altro worker e'
# nella direzione opposta sullo stesso strumento). Prima il PnL veniva
# calcolato col prezzo SIM per la gamba mai eseguita e sommato al ledger
# reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto).
# Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
# respinta diventa un ORFANO registrato (persistito) + alert Telegram.
from src.live.execution import contract_spec
for leg in (pf.leg_a, pf.leg_b):
if "netting" in (getattr(leg, "notes", "") or ""):
# reduce-only cappato/respinto, residuo in market puro (v1.1.25)
self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
self._notify("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
# verita' per-FRAZIONE di gamba (code-review 2026-06-11): una gamba puo'
# chiudere PARZIALMENTE (reduce-only cappato + netting negato/fallito) —
# si booka il gross della sola frazione FILLATA e l'orfano registra il
# solo RESIDUO (prima: gross binario tutto-o-niente e orfano a amount
# pieno, che falsava reconciler e real_capital della parte gia' chiusa).
filled_a = min(getattr(pf.leg_a, "filled_amount", 0.0), self.real_amount_a)
filled_b = min(getattr(pf.leg_b, "filled_amount", 0.0), self.real_amount_b)
step_a = contract_spec(self.inst_a).get("step", 0.001)
step_b = contract_spec(self.inst_b).get("step", 0.001)
ok_a = filled_a >= self.real_amount_a - step_a / 2
ok_b = filled_b >= self.real_amount_b - step_b / 2
frac_a = filled_a / self.real_amount_a if self.real_amount_a else 0.0
frac_b = filled_b / self.real_amount_b if self.real_amount_b else 0.0
exit_a = pf.leg_a.fill_price or sim_a
exit_b = pf.leg_b.fill_price or sim_b
# PnL per gamba: dir A = +d (long ratio compra A), dir B = -d
da, db = self.real_dir, -self.real_dir
gross_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a
gross_b = db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b
exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd
real_pnl = (gross_a * frac_a + gross_b * frac_b
- self.real_entry_fee - exit_fee)
self.real_capital += real_pnl
self.real_trades += 1
self._log("REAL_CLOSE_PAIR", {
"reason": reason, "exit_a": exit_a, "exit_b": exit_b,
"leg_a_ok": ok_a, "leg_b_ok": ok_b,
"filled_a": filled_a, "filled_b": filled_b,
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4),
"entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5),
"real_capital": round(self.real_capital, 4), "verified": pf.verified})
for ok, inst, side, amt, filled, step in (
(ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a),
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)):
residue = amt - filled
if not ok and residue >= step / 2:
orphan = {"instrument": inst, "entry_side": side,
"amount": round(residue, 8),
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
self.orphan_legs.append(orphan)
self._notify("PAIR_LEG_ORPHAN", {
"worker": self.worker_id, **orphan,
"note": ("gamba NON chiusa per il residuo indicato (netting "
"negato/fallito): posizione orfana sul conto — "
"risolvere e RIMUOVERE l'orfano dallo status")})
self.real_in_position = False
self.real_dir = 0
self.real_side_a = self.real_side_b = ""
self.real_amount_a = self.real_amount_b = 0.0
self.real_entry_a = self.real_entry_b = 0.0
self.real_notional_a = self.real_notional_b = 0.0
self.real_entry_fee = 0.0
self._save_state()
# applied (real-truth) SOLO se entrambe le gambe hanno chiuso verificate:
# con una gamba orfana il "PnL reale dello spread" non esiste -> meglio il
# fallback sim DICHIARATO che un numero mezzo-reale
return real_pnl, ok_a and ok_b
def _close(self, ca: float, cb: float, z: float, reason: str):
if not self.in_position:
return
ret_a = (ca - self.entry_a) / self.entry_a
ret_b = (cb - self.entry_b) / self.entry_b
gross = (ret_a - ret_b) * self.direction * self.leverage
fee = 2 * self.fee_rt * self.leverage # 2 gambe
net = gross - fee
sim_pnl = self.capital * self.position_size * net
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger (come StrategyWorker)
real_pnl, real_applied = (None, False)
if self.execution_enabled:
real_pnl, real_applied = self._real_close_pair(ca, cb, reason, sim_pnl)
use_real = self.real_truth and real_applied
pnl = real_pnl if use_real else sim_pnl
self.capital = max(self.capital + pnl, 0.0)
is_win = pnl > 0
self.total_trades += 1
self.total_wins += is_win
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
data = {"reason": reason, "exit_a": round(ca, 4), "exit_b": round(cb, 4),
"z": round(z, 3), "gross_ret": round(gross * 100, 3), "fee": round(fee * 100, 3),
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
"capital": round(self.capital, 2), "bars_held": self.bars_held,
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
if self.real_truth:
data["pnl_source"] = "real" if use_real else "sim_fallback"
data["sim_pnl"] = round(sim_pnl, 2)
if real_pnl is not None:
data["real_pnl"] = round(real_pnl, 4)
self._log("CLOSE", data); self._notify("CLOSED", data)
self.in_position = False
self.direction = 0
self.entry_a = self.entry_b = self.entry_z = 0.0
self.bars_held = 0
def tick(self, df_a: pd.DataFrame, df_b: pd.DataFrame):
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
if df_a is None or df_b is None or df_a.empty or df_b.empty:
return
# merge OHLC quando disponibile (serve a rilevare le candele flat per il flat-skip);
# se le colonne OHLC mancano, flat resta False -> comportamento close-only invariato.
ohlc = ["open", "high", "low", "close"]
keep_a = ["timestamp"] + [c for c in ohlc if c in df_a.columns]
keep_b = ["timestamp"] + [c for c in ohlc if c in df_b.columns]
m = df_a[keep_a].merge(df_b[keep_b], on="timestamp", how="inner",
suffixes=("_a", "_b")).sort_values("timestamp").reset_index(drop=True)
# Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di
# barra COMPLETA, come il backtest (pairs_research: close settled) —
# lezione EXIT-16. Detection condivisa: src.live.bars.
from src.live.bars import last_bar_is_forming
if last_bar_is_forming(m["timestamp"].values):
m = m.iloc[:-1]
if len(m) < self.n + 2:
return
ca, cb = m["close_a"].values, m["close_b"].values
z, dr = self._zscore(ca, cb)
i = len(m) - 1
cur_ts = int(m["timestamp"].iloc[i])
zi = z[i]
if np.isnan(zi):
self._save_state(); return
# flat della barra corrente (entrambe le gambe): O=H=L=C in una delle due
flat_i = False
if self.flat_skip and {"open_a", "high_a", "low_a"}.issubset(m.columns) \
and {"open_b", "high_b", "low_b"}.issubset(m.columns):
fa = (m["open_a"].iloc[i] == m["high_a"].iloc[i] == m["low_a"].iloc[i] == ca[i])
fb = (m["open_b"].iloc[i] == m["high_b"].iloc[i] == m["low_b"].iloc[i] == cb[i])
flat_i = bool(fa or fb)
if self.in_position:
if cur_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = cur_ts
# arma l'uscita: |z|<=z_exit (rientro) o time-limit; poi esegui alla 1a barra pulita
if not self.exit_ready and (abs(zi) <= self.z_exit or self.bars_held >= self.max_bars):
self.exit_ready = True
if self.exit_ready and not flat_i:
reason = "mean_revert" if abs(zi) <= self.z_exit else "time_limit"
self._close(float(ca[i]), float(cb[i]), float(zi), reason)
self._save_state()
return
# cerca ingresso (no look-ahead: z[i] usa solo dati <= i); mai su barra stale
if dr[i] <= self.jump_max and not flat_i:
if zi <= -self.z_in:
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
elif zi >= self.z_in:
self._open(-1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
self._save_state()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
pos = ("LONG " + self.asset_a if self.direction == 1
else "SHORT " + self.asset_a if self.direction == -1 else "FLAT")
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {pos}")
-277
View File
@@ -1,277 +0,0 @@
"""Paper trader: loop principale che monitora, segnala e opera su Deribit testnet."""
from __future__ import annotations
import json
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pandas as pd
from src.live.cerbero_client import CerberoClient
from src.live.signal_engine import SignalEngine
from src.live.telegram_notifier import notify_event
LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades"
INSTRUMENT = "ETH_USDC-PERPETUAL"
TRAIN_INSTRUMENT = "ETH-PERPETUAL"
CURRENCY = "USDC"
RESOLUTION = "15"
LEVERAGE = 3
POSITION_PCT = 0.15
HOLD_BARS = 3
POLL_SECONDS = 60
LOOKBACK_DAYS = 60
TRAIN_LOOKBACK_DAYS = 365
VIRTUAL_CAPITAL = 1000.0 # simula capitale reale, ignora balance testnet
class PaperTrader:
def __init__(self):
self.client = CerberoClient()
self.engine = SignalEngine(bb_w=14, sq_thr=0.8, ml_thr=0.70)
self.virtual_capital = VIRTUAL_CAPITAL
self.in_position = False
self.position_entry_time: datetime | None = None
self.position_direction: str | None = None
self.position_entry_price: float = 0
self.position_size: float = 0
self.bars_held = 0
self.last_bar_ts: int = 0
LOG_DIR.mkdir(parents=True, exist_ok=True)
self.log_path = LOG_DIR / f"trades_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
self.status_path = LOG_DIR / "status.json"
def log(self, event: str, data: dict | None = None):
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
**(data or {}),
}
with open(self.log_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}")
notify_event(event, data)
def save_status(self):
status = {
"virtual_capital": round(self.virtual_capital, 2),
"in_position": self.in_position,
"direction": self.position_direction,
"entry_price": self.position_entry_price,
"position_size": self.position_size,
"entry_time": self.position_entry_time.isoformat() if self.position_entry_time else None,
"bars_held": self.bars_held,
"last_update": datetime.now(timezone.utc).isoformat(),
}
with open(self.status_path, "w") as f:
json.dump(status, f, indent=2)
def fetch_candles(self, days: int = LOOKBACK_DAYS, instrument: str | None = None) -> pd.DataFrame:
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
candles = self.client.get_historical(
instrument or TRAIN_INSTRUMENT,
start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"),
RESOLUTION,
)
if not candles:
return pd.DataFrame()
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
return df
def train_model(self):
self.log("TRAINING", {"lookback_days": TRAIN_LOOKBACK_DAYS, "instrument": TRAIN_INSTRUMENT})
df = self.fetch_candles(TRAIN_LOOKBACK_DAYS, TRAIN_INSTRUMENT)
if df.empty:
self.log("TRAINING_FAILED", {"reason": "no data"})
return False
result = self.engine.train(df, lookahead=HOLD_BARS)
self.log("TRAINING_DONE", result)
return "error" not in result
def open_position(self, direction: str, signal: dict):
ticker = self.client.get_ticker(INSTRUMENT)
price = ticker["last_price"]
notional = self.virtual_capital * POSITION_PCT * LEVERAGE
amount = round(notional / price, 3)
amount = max(amount, 0.001)
side = "buy" if direction == "buy" else "sell"
self.log("OPENING", {
"side": side,
"amount": amount,
"price": price,
"virtual_capital": round(self.virtual_capital, 2),
"notional": round(notional, 2),
"signal": signal,
})
try:
result = self.client.place_order(
instrument=INSTRUMENT,
side=side,
amount=amount,
order_type="market",
leverage=LEVERAGE,
label="pythagoras-squeeze",
)
self.in_position = True
self.position_direction = side
self.position_entry_price = price
self.position_size = amount
self.position_entry_time = datetime.now(timezone.utc)
self.bars_held = 0
self.log("OPENED", {"order_result": result})
except Exception as e:
self.log("OPEN_FAILED", {"error": str(e)})
def close_current_position(self, reason: str):
if not self.in_position:
return
ticker = self.client.get_ticker(INSTRUMENT)
exit_price = ticker["last_price"]
if self.position_direction == "buy":
trade_pnl = (exit_price - self.position_entry_price) * self.position_size
else:
trade_pnl = (self.position_entry_price - exit_price) * self.position_size
fee = self.position_size * (self.position_entry_price + exit_price) * 0.001
net_pnl = trade_pnl - fee
pnl_pct = net_pnl / self.virtual_capital * 100
self.log("CLOSING", {
"reason": reason,
"entry_price": self.position_entry_price,
"exit_price": exit_price,
"size": self.position_size,
"trade_pnl": round(trade_pnl, 2),
"fee": round(fee, 2),
"net_pnl": round(net_pnl, 2),
"pnl_pct": round(pnl_pct, 3),
"bars_held": self.bars_held,
"capital_before": round(self.virtual_capital, 2),
})
try:
result = self.client.close_position(INSTRUMENT)
self.virtual_capital += net_pnl
self.log("CLOSED", {
"result": result,
"net_pnl": round(net_pnl, 2),
"pnl_pct": round(pnl_pct, 3),
"virtual_capital": round(self.virtual_capital, 2),
})
except Exception as e:
self.log("CLOSE_FAILED", {"error": str(e)})
self.in_position = False
self.position_direction = None
self.position_entry_price = 0
self.position_size = 0
self.position_entry_time = None
self.bars_held = 0
def check_position_exit(self, df: pd.DataFrame):
if not self.in_position:
return
current_ts = df["timestamp"].iloc[-1]
if current_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = current_ts
if self.bars_held >= HOLD_BARS:
self.close_current_position("hold_limit")
return
price = df["close"].iloc[-1]
if self.position_direction == "buy":
pnl_pct = (price - self.position_entry_price) / self.position_entry_price
else:
pnl_pct = (self.position_entry_price - price) / self.position_entry_price
if pnl_pct <= -0.02:
self.close_current_position("stop_loss_2pct")
def run_once(self) -> str:
"""Esegui un singolo ciclo. Ritorna lo stato."""
df = self.fetch_candles(LOOKBACK_DAYS, TRAIN_INSTRUMENT)
if df.empty:
return "no_data"
if self.in_position:
self.check_position_exit(df)
self.save_status()
if self.in_position:
return f"in_position_{self.position_direction}_bar{self.bars_held}"
return "position_closed"
signal = self.engine.check_signal(df)
if signal:
self.log("SIGNAL", signal)
self.open_position(signal["direction"], signal)
self.save_status()
return f"signal_{signal['direction']}"
self.save_status()
return "watching"
def run(self, retrain_hours: int = 24):
"""Loop principale."""
print("=" * 60)
print(f" PAPER TRADER — {INSTRUMENT} (margine {CURRENCY})")
print(f" Segnali da: {TRAIN_INSTRUMENT} {RESOLUTION}m")
print(f" Leva: {LEVERAGE}x, Position: {POSITION_PCT*100:.0f}%, Hold: {HOLD_BARS} barre")
print(f" Poll: ogni {POLL_SECONDS}s")
print(f" Log: {self.log_path}")
print("=" * 60)
account = self.client.get_account_summary()
self.log("STARTUP", {
"virtual_capital": self.virtual_capital,
"testnet_equity": account["equity"],
"testnet": account.get("testnet", True),
})
if not self.train_model():
print("Training fallito. Uscita.")
return
last_train = datetime.now(timezone.utc)
while True:
try:
now = datetime.now(timezone.utc)
if (now - last_train).total_seconds() > retrain_hours * 3600:
self.train_model()
last_train = now
status = self.run_once()
if status != "watching":
print(f"{status}")
except KeyboardInterrupt:
self.log("SHUTDOWN", {"reason": "keyboard"})
if self.in_position:
self.close_current_position("shutdown")
break
except Exception as e:
self.log("ERROR", {"error": str(e)})
print(f" ERRORE: {e}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
trader = PaperTrader()
trader.run()
-143
View File
@@ -1,143 +0,0 @@
"""RotationWorker (ROT02): dual-momentum top-k risk-gated, ribilancio giornaliero.
Replica live di honest_improve2._rot_daily_equity (lookback 60, top_k 3, gross 0.45, SMA100 gate)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
FEE_RT = 0.001
def _panel(data: dict, universe: list):
"""Allinea {asset: df} sui timestamp comuni -> (df_panel, cols presenti).
Scarta la barra IN FORMAZIONE (riga -1 = candela in corso finche' non e'
trascorsa la sua durata): ROT02/TSM01 valutavano momentum/regime e bookavano
il return sulla barra 1d parziale al primo poll dopo mezzanotte UTC, poi
last_bar_ts bloccava la rivalutazione a giorno chiuso. Stessa lezione EXIT-16
gia' applicata a fade/TR01/Pairs (detection condivisa src.live.bars)."""
from src.live.bars import last_bar_is_forming
frames = {}
for a in universe:
df = data.get(a)
if df is not None and len(df):
frames[a] = df[["timestamp", "close"]].rename(columns={"close": a})
if not frames:
return None, []
panel = None
for a, f in frames.items():
panel = f if panel is None else panel.merge(f, on="timestamp", how="inner")
panel = panel.sort_values("timestamp").reset_index(drop=True)
if len(panel) and last_bar_is_forming(panel["timestamp"].values):
panel = panel.iloc[:-1].reset_index(drop=True)
cols = [a for a in universe if a in frames]
return panel, cols
def _warn_panel_short(worker_id: str, panel, cols: list, need: int,
last_bar_ts: int, already_warned: bool) -> bool:
"""WARN (log + Telegram) quando il panel inner-join e' troncato sotto il lookback
richiesto e tick() salterebbe in SILENZIO (worker inerte senza segnale di vita).
Gated su last_bar_ts != 0 ("era gia' operativo") per evitare falsi positivi al
cold-start; una notifica per episodio. Ritorna il nuovo flag warned."""
if not last_bar_ts: # mai stato operativo: cold-start, non un guasto
return already_warned
if already_warned:
return True
from src.live.telegram_notifier import notify_event
got = 0 if panel is None else len(panel)
msg = {"worker": worker_id, "panel_rows": got, "need": need,
"assets": cols or "nessuno (BTC mancante?)"}
print(f" [{worker_id}] WARN panel corto: {got}/{need} righe — tick saltato")
notify_event("PANEL_SHORT", msg)
return True
class RotationWorker:
def __init__(self, universe, lookback=60, top_k=3, gross=0.45, regime_n=100,
tf="1d", capital=1000.0, fee_rt=FEE_RT, name="ROT02_rot",
data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.lookback = lookback
self.top_k = top_k
self.gross = gross
self.regime_n = regime_n
self.tf = tf
self.initial_capital = capital
self.capital = capital
self.fee_rt = fee_rt
self.worker_id = f"{name}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.weights = {a: 0.0 for a in self.universe}
self.last_bar_ts = 0
self.in_position = False
self._panel_warned = False # dedup WARN panel corto (per episodio, non persistito)
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
self.last_bar_ts = s.get("last_bar_ts", 0)
self.in_position = any(v > 0 for v in self.weights.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "weights": self.weights,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
need = max(self.lookback + 1, self.regime_n + 1)
panel, cols = _panel(data, self.universe)
if panel is None or len(panel) < need or "BTC" not in cols:
self._panel_warned = _warn_panel_short(
self.worker_id, panel, cols, need, self.last_bar_ts, self._panel_warned)
return
self._panel_warned = False
P = panel[cols].values
bar_ts = int(panel["timestamp"].iloc[-1])
# 1) realizza il rendimento dei pesi correnti sull'ultima barra chiusa
if self.last_bar_ts and bar_ts > self.last_bar_ts:
day_ret = P[-1] / P[-2] - 1.0
port_r = sum(self.weights.get(cols[k], 0.0) * day_ret[k] for k in range(len(cols)))
self.capital = max(self.capital * (1.0 + float(port_r)), 10.0)
# 2) ricalcola pesi target
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(self.regime_n).mean().values
risk_on = btc[-1] > bma[-1] if not np.isnan(bma[-1]) else False
mom = P[-1] / P[-1 - self.lookback] - 1.0
order = np.argsort(mom)[::-1]
chosen = [k for k in order if mom[k] > 0][: self.top_k] if risk_on else []
nw = {a: 0.0 for a in self.universe}
for k in chosen:
nw[cols[k]] = self.gross / len(chosen)
# 3) fee sul turnover
turnover = sum(abs(nw[a] - self.weights.get(a, 0.0)) for a in self.universe)
self.capital -= self.capital * turnover * (self.fee_rt / 2)
if turnover > 0:
self._log(nw, float(self.capital))
self.weights = nw
self.last_bar_ts = bar_ts
self.in_position = any(v > 0 for v in nw.values())
self._save()
def _log(self, weights, cap):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"weights": {a: round(w, 4) for a, w in weights.items() if w > 0},
"capital": round(cap, 2)}) + "\n")
@property
def status_summary(self):
held = {a: round(w, 3) for a, w in self.weights.items() if w > 0}
return f"{self.worker_id}: cap={self.capital:.0f} held={held}"
-284
View File
@@ -1,284 +0,0 @@
"""Motore segnali: squeeze detection + ML confirmation su dati live."""
from __future__ import annotations
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.preprocessing import StandardScaler
def keltner_ratio(close: np.ndarray, high: np.ndarray, low: np.ndarray, window: int = 14) -> np.ndarray:
n = len(close)
result = np.full(n, np.nan)
for i in range(window, n):
wc = close[i - window : i]
wh = high[i - window : i]
wl = low[i - window : i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc, 1)), np.abs(wl - np.roll(wc, 1))))
atr = np.mean(tr[1:])
kc_r = (ma + 1.5 * atr) - (ma - 1.5 * atr)
bb_r = (ma + 2 * bb_std) - (ma - 2 * bb_std)
if kc_r > 0:
result[i] = bb_r / kc_r
return result
def build_features(df: pd.DataFrame, i: int, squeeze_duration: int, squeeze_avg_vol: float, kcr_val: float) -> np.ndarray | None:
if i < 100 or i >= len(df):
return None
o = df["open"].values
h = df["high"].values
l = df["low"].values
c = df["close"].values
v = df["volume"].values
feats = []
for w in [12, 24, 48]:
if i < w:
feats.extend([0] * 12)
continue
win_c = c[i - w : i]
win_o = o[i - w : i]
win_h = h[i - w : i]
win_l = l[i - w : i]
win_v = v[i - w : i]
mn, mx = win_l.min(), max(win_h.max(), win_c.max())
rng = mx - mn if mx - mn > 0 else 1e-10
total = win_h - win_l
total = np.where(total == 0, 1e-10, total)
body = np.abs(win_c - win_o) / total
direction = np.sign(win_c - win_o)
log_c = np.log(np.where(win_c == 0, 1e-10, win_c))
rets = np.diff(log_c)
v_mean = np.mean(win_v)
feats.extend([
np.mean(rets) if len(rets) > 0 else 0,
np.std(rets) if len(rets) > 0 else 0,
np.sum(rets) if len(rets) > 0 else 0,
float(pd.Series(rets).skew()) if len(rets) > 2 else 0,
float(pd.Series(rets).kurtosis()) if len(rets) > 3 else 0,
np.mean(body),
np.std(body),
np.mean(direction),
np.mean(direction[-min(3, w):]),
(win_c[-1] - mn) / rng,
win_v[-1] / v_mean if v_mean > 0 else 1,
np.corrcoef(rets[:-1], rets[1:])[0, 1] if len(rets) > 1 and np.std(rets) > 0 else 0,
])
feats.extend([
squeeze_duration,
squeeze_duration / (24 * 4),
kcr_val,
v[i - 1] / squeeze_avg_vol if squeeze_avg_vol > 0 else 1,
np.mean(v[max(0, i - 3) : i]) / squeeze_avg_vol if squeeze_avg_vol > 0 else 1,
])
h48 = np.max(h[max(0, i - 48) : i])
l48 = np.min(l[max(0, i - 48) : i])
r48 = h48 - l48
feats.append((c[i - 1] - l48) / r48 if r48 > 0 else 0.5)
tr = np.maximum(h[i - 14 : i] - l[i - 14 : i],
np.maximum(np.abs(h[i - 14 : i] - np.roll(c[i - 14 : i], 1)),
np.abs(l[i - 14 : i] - np.roll(c[i - 14 : i], 1))))
atr = np.mean(tr[1:])
feats.append(atr / c[i - 1] if c[i - 1] > 0 else 0)
first_ret = (c[i - 1] - c[i - 2]) / c[i - 2] if i >= 2 and c[i - 2] > 0 else 0
feats.append(first_ret)
return np.nan_to_num(np.array(feats), nan=0, posinf=1e6, neginf=-1e6)
class SignalEngine:
"""Rileva squeeze e genera segnali ML in real-time."""
def __init__(self, bb_w: int = 14, sq_thr: float = 0.8, ml_thr: float = 0.70, min_squeeze_bars: int = 5):
self.bb_w = bb_w
self.sq_thr = sq_thr
self.ml_thr = ml_thr
self.min_squeeze_bars = min_squeeze_bars
self.model: GradientBoostingClassifier | None = None
self.scaler: StandardScaler | None = None
self.in_squeeze = False
self.squeeze_start_idx = 0
self.trained = False
def _new_model(self) -> GradientBoostingClassifier:
return GradientBoostingClassifier(
n_estimators=150, max_depth=4, min_samples_leaf=10,
learning_rate=0.05, subsample=0.8, random_state=42,
)
def _validate_oos(self, X: np.ndarray, y: np.ndarray, test_frac: float = 0.2) -> dict:
"""Split temporale (no shuffle) per stimare la performance out-of-sample.
Allena su training iniziale e valuta sull'ultimo `test_frac` dei campioni.
Oltre all'accuratezza OOS, riporta la precisione sui soli segnali con
confidenza >= ml_thr — cioè i trade che la strategia aprirebbe davvero.
"""
n_test = int(len(X) * test_frac)
n_train = len(X) - n_test
if n_train < 30 or n_test < 5:
return {"oos_warning": "test set troppo piccolo", "oos_test_samples": n_test}
scaler = StandardScaler()
X_tr = scaler.fit_transform(X[:n_train])
X_te = scaler.transform(X[n_train:])
y_tr, y_te = y[:n_train], y[n_train:]
model = self._new_model()
model.fit(X_tr, y_tr)
up_idx = list(model.classes_).index(1)
p_up = model.predict_proba(X_te)[:, up_idx]
test_acc = float(np.mean((p_up >= 0.5).astype(int) == y_te) * 100)
oos_train_acc = float(np.mean(model.predict(X_tr) == y_tr) * 100)
long_sig = p_up >= self.ml_thr
short_sig = p_up <= (1 - self.ml_thr)
n_sig = int((long_sig | short_sig).sum())
if n_sig > 0:
correct = int(((long_sig & (y_te == 1)) | (short_sig & (y_te == 0))).sum())
sig_prec = round(correct / n_sig * 100, 1)
else:
sig_prec = None
return {
"oos_train_accuracy": round(oos_train_acc, 1),
"oos_test_accuracy": round(test_acc, 1),
"oos_test_samples": n_test,
"oos_signals": n_sig,
"oos_signal_precision": sig_prec,
}
def train(self, df: pd.DataFrame, lookahead: int = 3) -> dict:
"""Addestra il modello su dati storici."""
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
kcr = keltner_ratio(close, high, low, self.bb_w)
X_all, y_all = [], []
in_sq = False
sq_start = 0
for i in range(self.bb_w + 1, n - lookahead):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < self.sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
duration = i - sq_start
if duration < self.min_squeeze_bars:
continue
avg_vol = np.mean(volume[sq_start:i])
feats = build_features(df, i, duration, avg_vol, kcr[i])
if feats is None:
continue
actual = (close[i + lookahead - 1] - close[i - 1]) / close[i - 1]
X_all.append(feats)
y_all.append(1 if actual > 0 else 0)
if len(X_all) < 30:
return {"error": "not enough training samples", "samples": len(X_all)}
X = np.array(X_all)
y = np.array(y_all)
oos = self._validate_oos(X, y)
self.scaler = StandardScaler()
X_s = self.scaler.fit_transform(X)
self.model = self._new_model()
self.model.fit(X_s, y)
self.trained = True
preds = self.model.predict(X_s)
train_acc = float(np.mean(preds == y) * 100)
return {
"samples": len(X),
"up_ratio": round(float(np.mean(y) * 100), 1),
"train_accuracy": round(train_acc, 1),
**oos,
}
def check_signal(self, df: pd.DataFrame) -> dict | None:
"""Controlla se c'è un segnale sulle ultime candele.
Ritorna dict con direzione e probabilità, oppure None.
"""
if not self.trained:
return None
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
kcr = keltner_ratio(close, high, low, self.bb_w)
if n < self.bb_w + 10:
return None
last_kcr = kcr[-1]
prev_kcr = kcr[-2] if n > 1 else np.nan
if np.isnan(last_kcr) or np.isnan(prev_kcr):
return None
was_squeeze = prev_kcr < self.sq_thr
is_released = last_kcr >= self.sq_thr
if not (was_squeeze and is_released):
self.in_squeeze = prev_kcr < self.sq_thr
if self.in_squeeze and not hasattr(self, '_sq_start_tracking'):
self._sq_start_tracking = n - 1
if not self.in_squeeze:
self._sq_start_tracking = None
return None
sq_start = getattr(self, '_sq_start_tracking', n - 10)
if sq_start is None:
sq_start = n - 10
duration = (n - 1) - sq_start
if duration < self.min_squeeze_bars:
self._sq_start_tracking = None
return None
avg_vol = np.mean(volume[max(0, sq_start) : n - 1])
feats = build_features(df, n - 1, duration, avg_vol, last_kcr)
self._sq_start_tracking = None
if feats is None:
return None
feats_s = self.scaler.transform(feats.reshape(1, -1))
proba = self.model.predict_proba(feats_s)[0]
up_idx = list(self.model.classes_).index(1)
p_up = proba[up_idx]
if p_up >= self.ml_thr:
return {"direction": "buy", "probability": p_up, "squeeze_duration": duration}
elif p_up <= (1 - self.ml_thr):
return {"direction": "sell", "probability": 1 - p_up, "squeeze_duration": duration}
return None
-58
View File
@@ -1,58 +0,0 @@
"""Import dinamico delle classi Strategy da scripts/strategies/."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from src.strategies.base import Strategy
PROJECT_ROOT = Path(__file__).resolve().parents[2]
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
_REGISTRY: dict[str, type[Strategy]] = {}
# Solo strategie con edge netto validato out-of-sample (fee-aware).
# La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD) e' stata spostata in
# scripts/waste/: l'edge storico era un artefatto di look-ahead
# (vedi scripts/analysis/oos_validation.py).
MODULE_MAP = {
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
# SH01 Shape-ML: generate_signals fa walk-forward (riallena il modello) -> pesante
# per-tick. Caricabile per backtest; per il live serve un worker con retraining
# periodico (come il legacy signal_engine), NON lo StrategyWorker a regola fissa.
"SH01_shape_ml": ("SH01_shape_ml", "ShapeMLStrategy"),
}
def load_strategy(name: str) -> Strategy:
"""Carica e istanzia una Strategy per nome."""
if name in _REGISTRY:
return _REGISTRY[name]()
if name not in MODULE_MAP:
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
module_file, class_name = MODULE_MAP[name]
module_path = STRATEGIES_DIR / f"{module_file}.py"
if not module_path.exists():
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls = getattr(module, class_name)
_REGISTRY[name] = cls
return cls()
def list_available() -> list[str]:
return list(MODULE_MAP.keys())
-770
View File
@@ -1,770 +0,0 @@
"""Worker per singola strategia — paper trading con stato persistente."""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal
from src.strategies.fade_base import atr as _atr
from src.live.telegram_notifier import notify_event
from src.live.execution import ExecutionClient
FEE_RT = 0.002
# Alert REAL_DIVERGENCE: |slippage sim/reale| oltre questa soglia a open/close ->
# Telegram. Cattura gli spike print testnet (2026-06-07: sim short BTC a 65266.5 con
# mark reale 62395, -440bps, passato in silenzio) e i feed stantii.
DIVERGENCE_BPS = 100.0
class StrategyWorker:
"""Gestisce paper trading per una singola strategia/asset/tf."""
def __init__(
self,
strategy: Strategy,
asset: str,
tf: str,
capital: float = 1000.0,
position_size: float = 0.15,
leverage: float = 3.0,
hold_bars: int = 3,
params: dict | None = None,
data_dir: Path = Path("data/paper_trades"),
executor: ExecutionClient | None = None,
exec_instrument: str | None = None,
real_truth: bool = False,
):
self.strategy = strategy
self.asset = asset
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.hold_bars = hold_bars
self.params = params or {}
# --- Esecuzione REALE (shadow): se attiva, ogni open/close sim e' affiancato
# da un ordine reale su Deribit (lineare USDC), con ledger reale parallelo. ---
self.executor = executor
self.exec_instrument = exec_instrument
self.execution_enabled = bool(executor and exec_instrument)
# REAL-TRUTH (2026-06-10): il ledger che guida il portafoglio (`capital`) si
# aggiorna col PnL dei FILL REALI (fee reali incluse); il sim resta solo
# diagnostica nel log CLOSE. Fallback al sim SOLO se il trade reale non e'
# mai esistito/fillato (REAL_OPEN_FAIL, fill zero) — flag pnl_source nel log.
self.real_truth = bool(real_truth and self.execution_enabled)
self.real_capital = capital
self.real_in_position = False
self.real_side = "" # "buy" | "sell" dell'apertura reale
self.real_amount = 0.0 # amount Deribit (base-coin) da richiudere
self.real_entry_price = 0.0
self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata
self.real_order_id = ""
self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume)
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
self._inverted_tp_ts = 0 # dedup log INVERTED_TP_SKIP per barra
self._inverted_tp_notified = False # alert Telegram una tantum per processo
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.trades_path = self.work_dir / "trades.jsonl"
self.status_path = self.work_dir / "status.json"
self.capital = capital
self.in_position = False
self.direction: int = 0
self.entry_price: float = 0
self.entry_time: str = ""
self.bars_held: int = 0
self.total_trades: int = 0
self.total_wins: int = 0
self.started_at = datetime.now(timezone.utc).isoformat()
self.last_bar_ts: int = 0
# Exit guidati dalla strategia via Signal.metadata (0 = usa hold_bars/stop legacy)
self.tp: float = 0.0
self.sl: float = 0.0
self.max_bars: int = 0
# EXIT-16 close-confirm SL (2026-06-04, fade): se settato nei params dello
# sleeve, lo SL intrabar e' disattivato e lo stop scatta solo se il CLOSE
# sfonda sl di sl_confirm_atr*ATR14 (immune ai wick). TP intrabar invariato.
self.sl_confirm_atr: float | None = (
float(self.params["sl_confirm_atr"])
if self.params.get("sl_confirm_atr") else None)
# Fee dalla strategia (MR01 = 0.001 realistico Deribit), fallback al default modulo
self.fee_rt: float = float(getattr(strategy, "fee_rt", FEE_RT))
self._load_state()
self._save_state()
def _inherit_lineage_capital(self):
"""Al primo avvio (nessun status.json) eredita capital/real_capital dal
worker piu' recente di STESSA strategia+asset su altro timeframe (lineage).
Nato dallo swap fade 1h->15m (2026-06-12): i worker nuovi partivano
dall'allocazione del pool scartando il PnL accumulato dal gemello 1h
(-16.8 di equity fantasma, riallineata a mano). Eredita SOLO i ledger,
MAI la posizione (quella appartiene al worker vecchio)."""
try:
stem = f"{self.strategy.name}__{self.asset}__"
siblings = [d for d in self.work_dir.parent.glob(f"{stem}*")
if d.is_dir() and d.name != self.worker_id
and (d / "status.json").exists()]
if not siblings:
return
latest = max(siblings, key=lambda d: (d / "status.json").stat().st_mtime)
with open(latest / "status.json") as f:
old = json.load(f)
if old.get("capital") is not None:
self.capital = float(old["capital"])
if old.get("real_capital") is not None:
self.real_capital = float(old["real_capital"])
self._log("INIT_LINEAGE", {"da": latest.name,
"capital": round(self.capital, 2),
"real_capital": round(self.real_capital, 2)})
except Exception as e: # mai bloccare il boot per il lineage
print(f" [{self.worker_id}] WARN lineage: {e}")
def _load_state(self):
"""Riprende stato da status.json se esiste."""
if not self.status_path.exists():
self._inherit_lineage_capital()
self._log("INIT", {"capital": round(self.capital, 2),
"real_capital": round(self.real_capital, 2),
"strategy": self.strategy.name,
"asset": self.asset, "tf": self.tf})
return
with open(self.status_path) as f:
state = json.load(f)
self.capital = state.get("capital", self.initial_capital)
self.in_position = state.get("in_position", False)
self.direction = state.get("direction", 0)
self.entry_price = state.get("entry_price", 0)
self.entry_time = state.get("entry_time", "")
self.bars_held = state.get("bars_held", 0)
self.total_trades = state.get("total_trades", 0)
self.total_wins = state.get("total_wins", 0)
self.started_at = state.get("started_at", self.started_at)
self.last_bar_ts = state.get("last_bar_ts", 0)
self.tp = state.get("tp", 0.0)
self.sl = state.get("sl", 0.0)
self.max_bars = state.get("max_bars", 0)
self.real_capital = state.get("real_capital", self.initial_capital)
self.real_in_position = state.get("real_in_position", False)
self.real_side = state.get("real_side", "")
self.real_amount = state.get("real_amount", 0.0)
self.real_entry_price = state.get("real_entry_price", 0.0)
self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0)
self.real_entry_notional = state.get("real_entry_notional", 0.0)
self.real_order_id = state.get("real_order_id", "")
self.real_tp_order_id = state.get("real_tp_order_id", "")
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,
"in_position": self.in_position,
"real_capital": round(self.real_capital, 2),
"real_in_position": self.real_in_position})
def _save_state(self):
state = {
"capital": round(self.capital, 2),
"in_position": self.in_position,
"direction": self.direction,
"entry_price": self.entry_price,
"entry_time": self.entry_time,
"bars_held": self.bars_held,
"total_trades": self.total_trades,
"total_wins": self.total_wins,
"started_at": self.started_at,
"last_bar_ts": self.last_bar_ts,
"tp": self.tp,
"sl": self.sl,
"max_bars": self.max_bars,
"real_capital": round(self.real_capital, 4),
"real_in_position": self.real_in_position,
"real_side": self.real_side,
"real_amount": self.real_amount,
"real_entry_price": self.real_entry_price,
"real_entry_fee_usd": self.real_entry_fee_usd,
"real_entry_notional": self.real_entry_notional,
"real_order_id": self.real_order_id,
"real_tp_order_id": self.real_tp_order_id,
"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:
json.dump(state, f, indent=2)
def _log(self, event: str, data: dict | None = None):
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"worker": self.worker_id,
"event": event,
**(data or {}),
}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
def _notify(self, event: str, data: dict | None = None):
enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float, current_ts: int = 0):
meta = signal.metadata or {}
tp = float(meta.get("tp", 0.0) or 0.0)
# GUARD TP-invertito (2026-06-16): un wick transitorio puo' far calcolare alla
# strategia un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra
# wickata, entry al prezzo recuperato gia' oltre il proprio tp) -> l'exit intrabar
# `bar_high>=tp` (long) / `bar_low<=tp` (short) scatta a bars_held=0 in PERDITA,
# con churn di fee e TP reduce-only respinti (16-06: 8 giri MR02_BTC 15m, sim
# -17.9 / reale -2.3). Verita' d'esecuzione, zero parametri: se il TP e' gia'
# sfondato all'ingresso il segnale e' malformato -> NON apriamo. (cerotto testnet:
# il fix vero e' mainnet, dove l'arbitraggio elimina i wick-print.)
if tp and ((signal.direction == 1 and tp <= current_price) or
(signal.direction == -1 and tp >= current_price)):
data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"tp": round(tp, 2),
"note": "TP gia' sfondato all'ingresso (wick-print) -> entry soppressa",
}
if current_ts != self._inverted_tp_ts:
self._inverted_tp_ts = current_ts
self._log("INVERTED_TP_SKIP", data)
if not self._inverted_tp_notified:
self._inverted_tp_notified = True
self._notify("INVERTED_TP_SKIP", data)
return
notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0
self.in_position = True
self.direction = signal.direction
self.entry_price = current_price
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
self.tp = tp
self.sl = float(meta.get("sl", 0.0) or 0.0)
self.max_bars = int(meta.get("max_bars", 0) or 0)
trade_data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"size": round(size, 6),
"notional": round(notional, 2),
"capital": round(self.capital, 2),
"tp": round(self.tp, 2) if self.tp else None,
"sl": round(self.sl, 2) if self.sl else None,
}
self._log("OPEN", trade_data)
self._notify("OPENED", trade_data)
if self.execution_enabled:
self._real_open(signal.direction, current_price, notional)
def _real_open(self, direction: int, sim_price: float, notional: float):
"""Apertura REALE (shadow) accanto al fill simulato. Logga il confronto
prezzo-sim vs prezzo-eseguito e la fee reale Deribit."""
from src.live.execution import contract_spec
side = "buy" if direction == 1 else "sell"
fill = self.executor.open(self.exec_instrument, side, notional, label=self.worker_id)
slip_bps = ((fill.fill_price / sim_price - 1) * 1e4
if fill.fill_price and sim_price else None)
data = {
"instrument": self.exec_instrument,
"side": side,
"order_id": fill.order_id,
"amount": fill.amount,
"sim_price": round(sim_price, 2),
"real_fill": fill.fill_price,
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
"fee_usd": round(fill.fee_usd, 5),
"verified": fill.verified,
}
if fill.verified:
linear = contract_spec(self.exec_instrument).get("linear")
# amount FILLATO, non richiesto (audit 2026-06-11): il ledger deve
# seguire i contratti realmente sul conto
real_amt = fill.filled_amount or fill.amount
self.real_in_position = True
self.real_side = side
self.real_amount = real_amt
self.real_entry_price = fill.fill_price or sim_price
self.real_entry_fee_usd = fill.fee_usd
self.real_entry_notional = (real_amt * self.real_entry_price
if linear else real_amt)
self.real_order_id = fill.order_id or ""
self._log("REAL_OPEN", data)
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
self._notify("REAL_EXEC_LIVE", data)
self.real_first_notified = True
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
# il sim sta per bookare PnL che il reale non vede
self._log("REAL_DIVERGENCE", {"fase": "open", **data})
self._notify("REAL_DIVERGENCE", {"fase": "open", **data})
self._place_real_tp()
self._place_disaster_sl()
else:
self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes})
self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes})
def _place_real_tp(self):
"""LIMIT reduce-only appoggiato al TP della strategia (fix divergenza
sim/reale 2026-06-04: il market-on-poll usciva post-rimbalzo, +235 bps
sopra il livello TP). Copre la SOLA quota del worker. Se il piazzamento
fallisce si resta sul fallback market-on-poll di _real_close."""
self.real_tp_order_id = ""
if not (self.tp and self.real_amount > 0):
return
rest = self.executor.place_tp_limit(self.exec_instrument, self.real_side,
self.real_amount, self.tp,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"tp": round(self.tp, 2),
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_tp_order_id = rest.order_id
self._log("REAL_TP_RESTING", data)
else:
self._log("REAL_TP_FAIL", {**data, "note": rest.notes})
def _place_disaster_sl(self):
"""Disaster-bracket on-book (improvement-sweep 2026-06-06 P1): STOP_MARKET
reduce-only LONTANO (executor.disaster_sl_pct, ~30% dall'ingresso) sulla
SOLA quota del worker. Pura assicurazione per gli outage (poll-loop fermo
= posizione reale senza valutazione exit; ETH gap max storico 33% in 1h):
in operativita' normale non scatta mai. Se il piazzamento fallisce si
resta senza bracket (come prima del fix) — solo log, non fatale."""
self.real_dsl_order_id = ""
pct = getattr(self.executor, "disaster_sl_pct", None)
if not (pct and self.real_amount > 0 and self.real_entry_price > 0):
return
stop = self.real_entry_price * (1 - pct if self.real_side == "buy" else 1 + pct)
rest = self.executor.place_disaster_sl(self.exec_instrument, self.real_side,
self.real_amount, stop,
label=self.worker_id)
data = {
"instrument": self.exec_instrument,
"order_id": rest.order_id,
"stop": round(stop, 2),
"pct": pct,
"amount": self.real_amount,
"state": rest.order_state,
}
if rest.verified and rest.order_id:
self.real_dsl_order_id = rest.order_id
self._log("REAL_DSL_RESTING", data)
else:
self._log("REAL_DSL_FAIL", {**data, "note": rest.notes})
def _real_close(self, sim_exit: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE (reduce-only della quota worker) + confronto col sim.
Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo
se gia' fillato — cosi' nessun fill puo' arrivare DOPO la lettura) e
legge i fill reali dal trade history per order_id; solo la quota residua
viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit).
L'uscita take-profit reale avviene cosi' AL livello come nel backtest,
non al poll post-rimbalzo.
Ritorna (real_pnl, applied): applied=True se il PnL reale e' basato su
fill effettivi (o chiusura verificata) e puo' fare da verita' del ledger
in modalita' real-truth; False = nessuna posizione/fill reale."""
if not self.real_in_position:
return None, False
from src.live.execution import contract_spec
step = contract_spec(self.exec_instrument)["step"]
# 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce
# lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa →
# il reduce-only a valle filla 0 e il GUARD del netting in close_amount
# nega il residuo non-reduce-only — gap conto-vs-libri ~0 → niente
# ordine nudo; REAL_CLOSE esce verified=False + REAL_CLOSE_PARTIAL)
# NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
# order_not_found). 'order_not_found' = ordine non piu' in book (probabile
# trigger durante outage: il market a valle filla 0 -> verified=False).
# Altri errori (rete/transitorio): RETRY, poi alert Telegram — dimenticare
# un id con lo stop ancora in book lascia un ORFANO che puo' colpire la
# PROSSIMA posizione del worker.
if self.real_dsl_order_id:
def _dsl_cancel():
d = self.executor.cancel_order(self.real_dsl_order_id)
return (d, d.get("state") in ("cancelled", "untriggered"),
str(d.get("error", "")) == "order_not_found")
dres, ok, not_found = _dsl_cancel()
if not ok and not not_found:
time.sleep(self.executor.verify_sleep)
dres, ok, not_found = _dsl_cancel()
if not ok:
data = {"order_id": self.real_dsl_order_id, "res": dres,
"note": ("non in book: probabile trigger durante outage"
if not_found else
"stop forse ORFANO sul book — verificare a mano")}
self._log("REAL_DSL_CANCEL_FAIL", data)
if not not_found:
self._notify("REAL_DSL_CANCEL_FAIL", data)
self.real_dsl_order_id = ""
# 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history)
tp_amt, tp_px, tp_fee = 0.0, None, 0.0
tp_order_id = self.real_tp_order_id
if tp_order_id:
cres = self.executor.cancel_order(tp_order_id)
cancelled = cres.get("state") == "cancelled"
for _ in range(self.executor.verify_polls):
tp_amt, tp_px, tp_fee = self.executor.resting_fills(
self.exec_instrument, tp_order_id)
if tp_amt > 0 or cancelled:
break # cancel pulito = al piu' fill parziali gia' visti
time.sleep(self.executor.verify_sleep)
tp_amt = min(tp_amt, self.real_amount)
if tp_amt > 0 and not tp_px:
tp_px = self.tp or sim_exit # fallback: il limit filla al suo livello
# 2) quota residua → market reduce-only (mai close_position: strumento condiviso)
remainder = self.real_amount - tp_amt
fill = None
if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id)
# amount FILLATO, non richiesto (audit 2026-06-11) — e si booka anche a
# verified=False: nel Fill merged dal netting filled_amount conta gia'
# solo i fill RISCONTRATI; azzerarlo quando il leg residuo fallisce
# butterebbe via contratti realmente chiusi (code-review 2026-06-11)
market_amt = fill.filled_amount if fill else 0.0
if fill and "netting" in (getattr(fill, "notes", "") or ""):
# il reduce-only era cappato/respinto e il residuo e' andato in market
# puro (netting contro quote opposte) — solo osservabilita'
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": residual,
"note": ("close non completato (netting negato/leg fallito): "
"quota residua registrata in orphan_legs — "
"verificare col reconciler")}
self._log("REAL_CLOSE_PARTIAL", data)
self._notify("REAL_CLOSE_PARTIAL", data)
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
parts = [(a, p) for a, p in ((tp_amt, tp_px),
(market_amt, fill.fill_price if fill else None))
if a > 0 and p]
exit_price = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)
if parts else sim_exit)
exit_fee = tp_fee + (fill.fee_usd if fill else 0.0)
verified = (tp_amt + market_amt) >= self.real_amount - step / 2
rdir = 1 if self.real_side == "buy" else -1
price_change = (exit_price - self.real_entry_price) / self.real_entry_price \
if self.real_entry_price else 0.0
real_gross = rdir * price_change * self.real_entry_notional
real_fees = self.real_entry_fee_usd + exit_fee
real_pnl = real_gross - real_fees
self.real_capital += real_pnl
self.real_trades += 1
slip_bps = ((exit_price / sim_exit - 1) * 1e4
if exit_price and sim_exit else None)
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
div = {"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
"real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2),
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}
# anche su jsonl, non solo Telegram: gli episodi di slippage estremo
# devono restare interrogabili (l'audit 2026-06-11 ha dovuto ricostruirli)
self._log("REAL_DIVERGENCE", div)
self._notify("REAL_DIVERGENCE", div)
self._log("REAL_CLOSE", {
"reason": reason,
"order_id": fill.order_id if fill else tp_order_id,
"tp_order_id": tp_order_id or None,
"tp_filled_amount": tp_amt,
"market_amount": market_amt,
"sim_exit": round(sim_exit, 2),
"real_fill": round(exit_price, 2) if parts else None,
"slippage_bps": round(slip_bps, 2) if slip_bps is not None else None,
"entry_fee_usd": round(self.real_entry_fee_usd, 5),
"exit_fee_usd": round(exit_fee, 5),
"real_pnl_usd": round(real_pnl, 4),
"sim_pnl_usd": round(sim_pnl, 4),
"real_capital": round(self.real_capital, 4),
"verified": verified,
})
self.real_in_position = False
self.real_side = ""
self.real_amount = 0.0
self.real_entry_price = 0.0
self.real_entry_fee_usd = 0.0
self.real_entry_notional = 0.0
self.real_order_id = ""
self.real_tp_order_id = ""
self.real_dsl_order_id = ""
# applied: fill reali presenti (parts) o chiusura comunque verificata
return real_pnl, bool(parts) or verified
def _tp_phantom(self, current_price: float, current_ts: int) -> bool:
"""TP "toccato" solo nel feed? Il LIMIT resting sul book REALE e' l'oracolo:
se il prezzo avesse davvero scambiato al livello si sarebbe fillato (almeno
in parte). Tocco sim + resting a zero fill + prezzo corrente che NON ha
raggiunto il TP = spike-print del feed (testnet, 2026-06-11: 14 giri fantasma
su ETH, sim +4% l'uno, reale fee/spread l'uno) → exit SOPPRESSA, la posizione
continua il suo ciclo normale (SL close-confirm e max_bars restano attivi).
Zero parametri: e' un check di verita' d'esecuzione, non un filtro di
strategia; sui worker senza esecuzione reale ritorna False (parita' storica).
Fail-open: se la query fill fallisce (rete) si chiude come prima."""
if not (self.execution_enabled and self.real_in_position
and self.real_tp_order_id and self.tp):
return False
# prezzo gia' oltre il livello → tocco genuino anche senza fill (gap fra poll)
if (self.direction == 1 and current_price >= self.tp) or \
(self.direction == -1 and current_price <= self.tp):
return False
# TTL: il wick fantasma resta nella barra in formazione per ~1h → senza
# cache sono ~45-55 HTTP consecutivi per worker per barra (code-review).
# 120s di validita': un fill reale tardivo viene visto al massimo 2 min dopo.
cache_bar, cache_t = self._tp_phantom_cache
if cache_bar == current_ts and time.monotonic() - cache_t < 120:
return True
try:
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
self.real_tp_order_id)
except Exception:
return False
if tp_amt > 0:
return False
self._tp_phantom_cache = (current_ts, time.monotonic())
if current_ts != self._tp_phantom_ts:
self._tp_phantom_ts = current_ts
data = {"tp": self.tp, "price": current_price, "direction": self.direction,
"tp_order_id": self.real_tp_order_id,
"note": "wick solo nel feed (resting zero-fill, prezzo lontano dal livello) -> exit soppressa"}
self._log("TP_PHANTOM", data)
if not self._tp_phantom_notified:
self._tp_phantom_notified = True
self._notify("TP_PHANTOM", data)
return True
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
return
price_change = (current_price - self.entry_price) / self.entry_price
trade_return = price_change * self.direction
net = trade_return * self.leverage - self.fee_rt * self.leverage
sim_pnl = self.capital * self.position_size * net
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger; se i fill reali
# esistono il loro PnL (fee reali incluse) e' la verita' del capitale.
real_pnl, real_applied = (None, False)
if self.execution_enabled:
real_pnl, real_applied = self._real_close(current_price, reason, sim_pnl)
use_real = self.real_truth and real_applied
pnl = real_pnl if use_real else sim_pnl
is_win = pnl > 0 # win = profitto NETTO dopo fee (reali se real-truth)
self.capital += pnl
self.capital = max(self.capital, 0)
self.total_trades += 1
if is_win:
self.total_wins += 1
accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
trade_data = {
"reason": reason,
"direction": "long" if self.direction == 1 else "short",
"entry": round(self.entry_price, 2),
"exit": round(current_price, 2),
"pnl": round(pnl, 2),
"net_return": round(net * 100, 3),
"capital": round(self.capital, 2),
"bars_held": self.bars_held,
"win": is_win,
"total_trades": self.total_trades,
"accuracy": round(accuracy, 1),
}
if self.real_truth:
# diagnostica: sorgente del PnL applicato + sim a confronto
trade_data["pnl_source"] = "real" if use_real else "sim_fallback"
trade_data["sim_pnl"] = round(sim_pnl, 2)
if real_pnl is not None:
trade_data["real_pnl"] = round(real_pnl, 4)
self._log("CLOSE", trade_data)
self._notify("CLOSED", trade_data)
self.in_position = False
self.direction = 0
self.entry_price = 0
self.entry_time = ""
self.bars_held = 0
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.
df_1h: serie 1h live opzionale per strategie multi-timeframe (es. MT01),
passata ai generate_signals via params. Se None la strategia ricade sul
parquet statico.
"""
if df.empty or len(df) < 100:
return
c = df["close"].values
current_price = float(c[-1])
bar_high = float(df["high"].iloc[-1])
bar_low = float(df["low"].iloc[-1])
current_ts = int(df["timestamp"].iloc[-1])
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
if self.in_position:
if current_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = current_ts
if self.tp and self.sl and self.sl_confirm_atr:
# EXIT-16 close-confirm (2026-06-04): TP intrabar al livello come il
# backtest; lo SL scatta SOLO se il close sfonda sl ∓ buf*ATR14 — i
# wick che bucano lo stop e rientrano (l'overshoot che la fade fada)
# non stoppano piu'. PORT06: OOS Sharpe 8.82->10.06 (exit-lab, 34 agenti).
#
# FIX 2026-06-05: il confirm va valutato sul close di barra COMPLETATA,
# come nel backtest (fade_base: c[j] di bar chiusi) — NON sul prezzo
# della barra in formazione, che reintroduce la wick-sensitivity che
# EXIT-16 elimina (audit live: 2 stop su 3 del 2026-06-05 erano scattati
# su dip intrabar che il backtest avrebbe ignorato in quel momento).
# L'ultima riga del df e' la candela in corso se non e' ancora trascorsa
# la sua durata; il fill resta al prezzo corrente (lag di poll, stress
# lag_close_exit superato in exit-lab). Il buf usa l'ATR della stessa
# barra completata. Detection condivisa: src.live.bars.
from src.live.bars import last_settled_idx
k = last_settled_idx(df["timestamp"].values)
confirm_close = float(c[k])
buf = self.sl_confirm_atr * float(_atr(df, 14)[k])
if not np.isfinite(buf):
buf = 0.0
if self.direction == 1:
if bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif confirm_close < self.sl - buf:
self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif confirm_close > self.sl + buf:
self._close_position(current_price, "stop_loss")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.tp and self.sl:
# Exit INTRABAR come il backtest: si controllano high/low della barra (non solo il
# close) e si esce AL LIVELLO tp/sl. SL prima (conservativo), poi TP, poi time-limit.
if self.direction == 1:
if bar_low <= self.sl:
self._close_position(self.sl, "stop_loss")
elif bar_high >= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
else:
if bar_high >= self.sl:
self._close_position(self.sl, "stop_loss")
elif bar_low <= self.tp and not self._tp_phantom(current_price, current_ts):
self._close_position(self.tp, "take_profit")
elif self.max_bars and self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.max_bars:
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
if self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
elif self.bars_held >= self.hold_bars:
self._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction
if pnl_pct <= -0.02:
self._close_position(current_price, "stop_loss")
self._save_state()
return
# Genera segnali
extra = dict(self.params)
if df_1h is not None:
extra["df_1h"] = df_1h
signals = self.strategy.generate_signals(
df, ts, asset=self.asset, tf=self.tf, **extra
)
if not signals:
self._save_state()
return
last_signal = signals[-1]
last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price, current_ts)
self.last_bar_ts = current_ts
self._save_state()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT"
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t "
f"{acc:.0f}% | {pos}")
-55
View File
@@ -1,55 +0,0 @@
"""Notifiche Telegram per il paper trader."""
from __future__ import annotations
import os
import urllib.request
import urllib.parse
import json
from src.version import APP_VERSION
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
NOTIFY_EVENTS = {
"SIGNAL", "OPENED", "CLOSED", "OPEN_FAILED", "CLOSE_FAILED",
"ERROR", "STARTUP", "SHUTDOWN", "TRAINING_FAILED",
# esecuzione REALE (shadow su Deribit testnet)
"REAL_EXEC_LIVE", # primo ordine reale verificato di un worker (conferma "e' vivo")
"REAL_OPEN_FAIL", # un'apertura reale NON si e' verificata (problema da guardare)
"STALE_FEED", # feed flat/fermo da >= N barre 1h (worker ciechi: il prossimo
# prezzo reale puo' gappare, come ETH 2026-06-05 1655->1600)
"PANEL_SHORT", # TSM01/ROT02: panel inner-join troncato sotto il lookback
# richiesto -> tick() salterebbe in SILENZIO (worker inerte)
"FEED_OUTAGE", # N poll consecutivi falliti/degradati nel runner: exit non
# valutati, posizioni reali protette solo dal disaster-SL
"REAL_DIVERGENCE", # |slippage| sim/reale anomalo a open/close (es. spike print
# testnet: sim entra su un prezzo fantasma, il reale sul book)
"REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile
# stop ORFANO sul book -> verificare a mano
"REAL_CLOSE_FAILED", # chiusura reale a 2 gambe (pairs) non verificata su una gamba
}
def send_telegram(text: str) -> bool:
if not BOT_TOKEN or not CHAT_ID:
return False
try:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
data = urllib.parse.urlencode({"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}).encode()
urllib.request.urlopen(url, data, timeout=10)
return True
except Exception:
return False
def notify_event(event: str, data: dict | None = None):
if event not in NOTIFY_EVENTS:
return
lines = [f"📊 <b>{event}</b> <code>v{APP_VERSION}</code>"]
if data:
for k, v in data.items():
if k in ("signal",):
continue
lines.append(f" {k}: {v}")
send_telegram("\n".join(lines))
-97
View File
@@ -1,97 +0,0 @@
"""TsmomWorker (TSM01): consenso TSMOM multi-orizzonte risk-gated, ribilancio giornaliero.
Replica live di tsmom_research.tsmom_sim (horizons 63/126/252, thr 1.0, gross 0.30, SMA100 gate)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.live.rotation_worker import _panel, _warn_panel_short, FEE_RT
class TsmomWorker:
def __init__(self, universe, horizons=(63, 126, 252), thr=1.0, gross=0.30,
regime_n=100, tf="1d", capital=1000.0, fee_rt=FEE_RT,
name="TSM01", data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.horizons = tuple(horizons)
self.thr = thr
self.gross = gross
self.regime_n = regime_n
self.tf = tf
self.initial_capital = capital
self.capital = capital
self.fee_rt = fee_rt
self.worker_id = f"{name}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.weights = {a: 0.0 for a in self.universe}
self.last_bar_ts = 0
self.in_position = False
self._panel_warned = False # dedup WARN panel corto (per episodio, non persistito)
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
self.last_bar_ts = s.get("last_bar_ts", 0)
self.in_position = any(v > 0 for v in self.weights.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "weights": self.weights,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
need = max(max(self.horizons) + 1, self.regime_n + 1)
panel, cols = _panel(data, self.universe)
if panel is None or len(panel) < need or "BTC" not in cols:
self._panel_warned = _warn_panel_short(
self.worker_id, panel, cols, need, self.last_bar_ts, self._panel_warned)
return
self._panel_warned = False
P = panel[cols].values
bar_ts = int(panel["timestamp"].iloc[-1])
if self.last_bar_ts and bar_ts > self.last_bar_ts:
day_ret = P[-1] / P[-2] - 1.0
port_r = sum(self.weights.get(cols[k], 0.0) * day_ret[k] for k in range(len(cols)))
self.capital = max(self.capital * (1.0 + float(port_r)), 10.0)
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(self.regime_n).mean().values
risk_on = btc[-1] > bma[-1] if not np.isnan(bma[-1]) else False
score = np.zeros(len(cols))
for h in self.horizons:
score += np.sign(P[-1] / P[-1 - h] - 1.0)
score /= len(self.horizons)
chosen = [k for k in range(len(cols)) if score[k] >= self.thr] if risk_on else []
nw = {a: 0.0 for a in self.universe}
for k in chosen:
nw[cols[k]] = self.gross / len(chosen)
turnover = sum(abs(nw[a] - self.weights.get(a, 0.0)) for a in self.universe)
self.capital -= self.capital * turnover * (self.fee_rt / 2)
if turnover > 0:
self._log(nw, float(self.capital))
self.weights = nw
self.last_bar_ts = bar_ts
self.in_position = any(v > 0 for v in nw.values())
self._save()
def _log(self, weights, cap):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"weights": {a: round(w, 4) for a, w in weights.items() if w > 0},
"capital": round(cap, 2)}) + "\n")
@property
def status_summary(self):
held = {a: round(w, 3) for a, w in self.weights.items() if w > 0}
return f"{self.worker_id}: cap={self.capital:.0f} held={held}"
-216
View File
@@ -1,216 +0,0 @@
"""CrossSectionalWorker — paper/live worker per XS01 (reversione cross-sectional, 8 asset).
Mirror ESATTO di scripts.strategies.XS01_cross_sectional.xsec_sim: ogni HOLD barre
classifica gli asset per rendimento su LB barre, pesi w = -(ret - media)/gross (market-
neutral gross 1), entra al close, esce dopo HOLD barre, riallinea (1 barra di stacco fra
uscita e nuovo ingresso, come l'engine). PnL su book log-return netto fee 0.10% RT.
Stato persistente (resume). Solo SIM (esecuzione reale a 8 gambe non implementata).
PHASE-TRANCHING (2026-06-11, gate xs01_tranche_gate.py): param `tranches`=K divide il
book in K sub-book sfasati di hold/K barre, capitale comune (PnL/K per tranche). La fase
del roll non-sovrapposto e' arbitraria e da sola muove Sharpe FULL daily 1.52-2.33 e DD
13.8-33.1% (timing-luck): l'ensemble di fase la elimina SENZA parametri fittati (plateau
K=2 e K=3 entrambi promossi; PORT06 OOS Sh 10.07->10.15, DD 1.48->1.38). Solo path live,
come disp_min: il backtest canonico resta single-phase. K=1 = comportamento storico.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from src.live.telegram_notifier import notify_event
class CrossSectionalWorker:
def __init__(self, universe, tf="1h", params=None, capital=1000.0,
position_size=0.15, leverage=3.0, fee_rt=0.0005,
name="XS01", data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
p = params or {}
self.lb = int(p.get("lb", 48))
self.hold = int(p.get("hold", 12))
# dispersion-gate (2026-06-10): entra solo se la std cross-section del
# momentum lb supera disp_min — senza dispersione da far rientrare i
# trade sono fee. None = off (parita' col backtest canonico non filtrato).
self.disp_min = p.get("disp_min")
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.k = max(1, int(p.get("tranches", 1)))
self._step = max(1, round(self.hold / self.k)) # sfasamento iniziale fra tranche
self.capital = capital
self.books = [self._flat_book(j * self._step) for j in range(self.k)]
self.total_trades = 0
self.total_wins = 0
self.last_bar_ts = 0
self._load()
def _flat_book(self, wait: int = 0):
return {"weights": {a: 0.0 for a in self.universe},
"entry_px": {a: 0.0 for a in self.universe},
"bars_held": 0, "in_position": False, "wait": int(wait)}
@property
def in_position(self) -> bool:
return any(b["in_position"] for b in self.books)
# ---------- persistenza ----------
def _load(self):
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "universe": self.universe,
"lb": self.lb, "hold": self.hold, "tranches": self.k})
return
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.initial_capital)
self.total_trades = s.get("total_trades", 0)
self.total_wins = s.get("total_wins", 0)
self.last_bar_ts = s.get("last_bar_ts", 0)
if "books" in s:
for j, bs in enumerate(s["books"][: self.k]):
b = self.books[j]
b["weights"] = {**{a: 0.0 for a in self.universe}, **bs.get("weights", {})}
b["entry_px"] = {**{a: 0.0 for a in self.universe}, **bs.get("entry_px", {})}
b["bars_held"] = int(bs.get("bars_held", 0))
b["in_position"] = bool(bs.get("in_position", False))
b["wait"] = int(bs.get("wait", 0))
elif s.get("in_position") or s.get("weights"):
# migrazione dallo schema legacy single-book: il vecchio book diventa la
# tranche 0; le altre partono flat col loro sfasamento (gia' in __init__)
b = self.books[0]
b["weights"] = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
b["entry_px"] = {**{a: 0.0 for a in self.universe}, **s.get("entry_px", {})}
b["bars_held"] = int(s.get("bars_held", 0))
b["in_position"] = bool(s.get("in_position", False))
b["wait"] = 0
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(float(self.capital), 2), "in_position": bool(self.in_position),
"tranches": int(self.k),
"books": [{"weights": {a: round(float(v), 5) for a, v in b["weights"].items()},
"entry_px": {a: float(v) for a, v in b["entry_px"].items()},
"bars_held": int(b["bars_held"]), "in_position": bool(b["in_position"]),
"wait": int(b["wait"])} for b in self.books],
"total_trades": int(self.total_trades), "total_wins": int(self.total_wins),
"last_bar_ts": int(self.last_bar_ts),
"last_update": datetime.now(timezone.utc).isoformat(),
}, indent=2))
def _log(self, event, data=None):
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
"event": event, **(data or {})}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry, default=str) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)[:160]}")
def _notify(self, event, data=None):
notify_event(event, {"worker": self.worker_id, **(data or {})})
# ---------- pannello allineato ----------
def _panel(self, data: dict):
frames = []
for a in self.universe:
df = data.get(a)
if df is None or df.empty:
return None
frames.append(df[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp"))
M = pd.concat(frames, axis=1, join="inner").sort_index()
# scarta la barra IN FORMAZIONE (close non settled) — come gli altri worker
from src.live.bars import last_bar_is_forming
ts = M.index.to_numpy()
if len(ts) and last_bar_is_forming(ts):
M = M.iloc[:-1]
return M
# ---------- weights (identici all'engine) ----------
def _weights(self, logC_row, logC_lb_row):
dm = logC_row - logC_lb_row
dm = dm - dm.mean()
w = -dm
gw = np.sum(np.abs(w))
return w / gw if gw > 1e-9 else None
def _close_book(self, b, closes_now, tranche: int):
"""Realizza il PnL del book della tranche al prezzo attuale (log-return netto fee).
Capitale comune: il notional della tranche e' 1/K del book virtuale."""
book = 0.0
for k, a in enumerate(self.universe):
book += b["weights"][a] * np.log(closes_now[k] / b["entry_px"][a])
# cast a tipi Python: i numpy (float64/int64/bool_) rompono json.dumps in _save
net = float(book - 2 * self.fee_rt)
pnl = float(self.capital * self.position_size * self.leverage * net / self.k)
self.capital = max(self.capital + pnl, 10.0)
self.total_trades += 1
self.total_wins += 1 if net > 0 else 0
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
self._log("CLOSE", {"tranche": tranche, "book_ret": round(book * 100, 3),
"net": round(net * 100, 3),
"pnl": round(pnl, 2), "capital": round(self.capital, 2),
"trades": self.total_trades, "acc": round(acc, 1)})
b["in_position"] = False
b["weights"] = {a: 0.0 for a in self.universe}
def _open_book(self, M, i, b, tranche: int):
cols = list(M.columns)
logC = np.log(M.values)
if self.disp_min is not None:
disp = float(np.nanstd(logC[i] - logC[i - self.lb]))
if disp < float(self.disp_min):
return # regime senza dispersione: skip entry
w = self._weights(logC[i], logC[i - self.lb])
if w is None:
return
closes = M.iloc[i].values
b["weights"] = {a: float(w[cols.index(a)]) for a in self.universe}
b["entry_px"] = {a: float(closes[cols.index(a)]) for a in self.universe}
b["bars_held"] = 0
b["in_position"] = True
self._log("OPEN", {"tranche": tranche,
"long": [a for a in self.universe if b["weights"][a] > 0.05],
"short": [a for a in self.universe if b["weights"][a] < -0.05],
"capital": round(self.capital, 2)})
# ---------- tick ----------
def tick(self, data: dict):
M = self._panel(data)
if M is None or len(M) < self.lb + 1: # serve close[i] e close[i-lb] -> lb+1 barre
return
i = len(M) - 1
cur_ts = int(M.index[i])
new_bar = cur_ts > self.last_bar_ts
for j, b in enumerate(self.books):
if b["in_position"]:
if new_bar:
b["bars_held"] += 1
# esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1
if b["bars_held"] >= self.hold:
self._close_book(b, M.iloc[i].values, j)
elif b["wait"] > 0:
if new_bar:
b["wait"] -= 1 # sfasamento iniziale della tranche
else:
self._open_book(M, i, b, j) # entra al bar corrente (i = lb alla prima volta)
# solo avanti: se il panel si accorcia per un feed in ritardo (inner join),
# non si regredisce — una barra gia' contata non va ricontata
self.last_bar_ts = max(self.last_bar_ts, cur_ts)
self._save()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
nb = sum(1 for b in self.books if b["in_position"])
st = f"BOOK {nb}/{self.k}" if nb else "FLAT"
return f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {st}"