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
-278
View File
@@ -1,278 +0,0 @@
"""Discovery + validazione strumenti per gli exchange implementati (via Cerbero MCP).
Per ogni exchange (Deribit, Hyperliquid — esclusi Alpaca/stocks e Bybit, il cui
feed testnet e' farlocco) enumera i perpetui, ne verifica i dati e produce un
registry di strumenti VALIDATI.
Solo gli strumenti nel registry possono essere usati per la raccolta dati
(vedi gate in src/data/downloader.py).
Controlli di validazione (uno strumento e' valido solo se TUTTI passano):
- exists : la storia daily ritorna candele
- ohlc_sane : high>=low, high>=max(o,c), low<=min(o,c), prezzi>0
- not_flat : non e' un contratto morto (quasi tutte le barre O==H==L==C)
- liquid : volume_24h>0 dal ticker
- congruent : il prezzo concorda (entro tolleranza) con la MEDIANA dello
stesso base-coin su tutti gli exchange. Scarta i feed testnet
farlocchi (es. Bybit BTC=300k) e i contratti sbagliati
(es. Deribit SOL-PERPETUAL=9.6 vs SOL reale ~82).
NB: il token Cerbero punta a TESTNET; la congruenza cross-exchange e' il filtro
che distingue i feed realistici (Deribit, Hyperliquid) da quelli farlocchi.
"""
from __future__ import annotations
import json
import statistics
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from src.live.cerbero_client import CerberoClient
REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / "instruments_registry.json"
# I nostri timestep -> codice risoluzione per ciascun exchange
TF_CODES = {
"deribit": {"1m": "1", "5m": "5", "15m": "15", "1h": "60", "1d": "1D"},
"hyperliquid": {"1m": "1m", "5m": "5m", "15m": "15m", "1h": "1h", "1d": "1d"},
}
CONGRUENCE_TOL = 0.05 # 5% di scostamento dalla mediana del base-coin
def _today() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
@dataclass
class Quote:
base: str
symbol: str
last: float | None = None
volume_24h: float | None = None
open_interest: float | None = None
# --------------------------- adapters ---------------------------
class ExchangeAdapter:
name = "base"
def __init__(self, client: CerberoClient):
self.c = client
def _post(self, tool: str, payload: dict) -> dict:
return self.c._post(f"/mcp-{self.name}/tools/{tool}", payload)
def list_symbols(self) -> list[Quote]:
"""Lista perpetui (economica). I prezzi possono essere None (vedi ticker)."""
raise NotImplementedError
def ticker(self, q: Quote) -> None:
"""Riempie last/volume/OI sul Quote (per-simbolo). No-op se gia' pieni."""
def candles(self, symbol: str, tf: str, start: str, end: str) -> pd.DataFrame:
raise NotImplementedError
class DeribitAdapter(ExchangeAdapter):
name = "deribit"
def list_symbols(self) -> list[Quote]:
perps, offset = [], 0
while True:
r = self._post("get_instruments", {"currency": "any", "kind": "future",
"offset": offset, "limit": 100})
insts = r.get("instruments", [])
perps += [i["name"] for i in insts if i.get("name", "").endswith("-PERPETUAL")]
if not r.get("has_more") or not insts:
break
offset += len(insts)
if offset > 2000:
break
out = []
for name in perps:
base = name.split("-PERPETUAL")[0].replace("_USDC", "").replace("_USD", "")
out.append(Quote(base, name))
return out
def ticker(self, q: Quote) -> None:
t = self._post("get_ticker", {"instrument": q.symbol})
q.last, q.volume_24h, q.open_interest = t.get("last_price"), t.get("volume_24h"), t.get("open_interest")
def candles(self, symbol, tf, start, end) -> pd.DataFrame:
r = self._post("get_historical", {"instrument": symbol, "start_date": start,
"end_date": end, "resolution": TF_CODES["deribit"][tf]})
return _to_df(r.get("candles", []))
class HyperliquidAdapter(ExchangeAdapter):
name = "hyperliquid"
def list_symbols(self) -> list[Quote]:
r = self._post("get_markets", {})
markets = r if isinstance(r, list) else r.get("markets", [])
return [Quote(m["asset"], m["asset"], m.get("mark_price"),
m.get("volume_24h"), m.get("open_interest")) for m in markets]
# prezzi gia' presenti da get_markets -> ticker no-op
def candles(self, symbol, tf, start, end) -> pd.DataFrame:
r = self._post("get_historical", {"asset": symbol, "start_date": start, "end_date": end,
"resolution": TF_CODES["hyperliquid"][tf], "limit": 5000})
return _to_df(r.get("candles", []))
ADAPTERS = {"deribit": DeribitAdapter, "hyperliquid": HyperliquidAdapter}
def _to_df(candles: list[dict]) -> pd.DataFrame:
if not candles:
return pd.DataFrame()
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
return df.sort_values("timestamp").reset_index(drop=True)
# --------------------------- validazione ---------------------------
def _ohlc_sane(df: pd.DataFrame) -> bool:
if df.empty:
return False
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
ok = (h >= l) & (h >= o) & (h >= c) & (l <= o) & (l <= c) & (c > 0) & (l > 0)
return bool(ok.mean() > 0.99)
def _not_flat(df: pd.DataFrame) -> bool:
if df.empty:
return False
flat = (df["open"] == df["high"]) & (df["high"] == df["low"]) & (df["low"] == df["close"])
return bool(flat.mean() < 0.90)
def build_registry(exchanges: list[str] | None = None,
tf_check: tuple[str, ...] = ("1m", "5m", "15m", "1h"),
start_scan_from: str = "2017-01-01",
save: bool = True) -> dict:
exchanges = exchanges or ["deribit", "hyperliquid"] # NO alpaca, NO bybit (testnet farlocco)
client = CerberoClient()
adapters = {ex: ADAPTERS[ex](client) for ex in exchanges}
# 1) lista economica per ogni exchange
listed: dict[str, list[Quote]] = {}
for ex, ad in adapters.items():
try:
listed[ex] = ad.list_symbols()
print(f" [{ex}] {len(listed[ex])} strumenti elencati")
except Exception as e:
print(f" [{ex}] discovery FALLITA: {type(e).__name__}: {e}")
listed[ex] = []
# 2) universo = base-coin presenti su Deribit (il nostro venue). Bybit/HL
# vengono validati solo sull'overlap (cross-check), non su 500+ simboli.
deribit_bases = {q.base for q in listed.get("deribit", [])}
selected: dict[str, list[Quote]] = {}
for ex, qs in listed.items():
selected[ex] = qs if ex == "deribit" else [q for q in qs if q.base in deribit_bases]
# 3) timeframe disponibili per exchange (testati su BTC recente)
ref = {"deribit": "BTC-PERPETUAL", "hyperliquid": "BTC"}
tf_by_ex: dict[str, list[str]] = {}
for ex, ad in adapters.items():
oks = []
for tf in tf_check:
try:
if not ad.candles(ref[ex], tf, _today(), _today()).empty:
oks.append(tf)
except Exception:
pass
tf_by_ex[ex] = oks
print(f" [{ex}] timeframe ok: {oks}")
# 4) UNA fetch daily per strumento: e' il dato che davvero raccoglieremmo.
# Da qui ricaviamo esistenza, OHLC, not-flat, start-date, prezzo-per-congruenza
# (ultima close STORICA, non il ticker) e liquidita' (volume daily recente).
scan: dict[tuple[str, str], dict] = {}
for ex, ad in adapters.items():
for q in selected[ex]:
rec = {"reasons": [], "last_close": None, "start_date": None, "vol": 0.0}
try:
d = ad.candles(q.symbol, "1d", start_scan_from, _today())
if d.empty:
rec["reasons"].append("no_history")
else:
if not _ohlc_sane(d):
rec["reasons"].append("ohlc_insane")
if not _not_flat(d):
rec["reasons"].append("flat_dead")
rec["last_close"] = float(d["close"].iloc[-1])
rec["vol"] = float(d["volume"].tail(7).mean())
rec["start_date"] = str(pd.to_datetime(d["timestamp"].iloc[0], unit="ms", utc=True).date())
except Exception as e:
rec["reasons"].append(f"history_err:{type(e).__name__}")
scan[(ex, q.symbol)] = rec
# 5) mediana per base-coin dall'ULTIMA CLOSE STORICA (riferimento congruenza)
by_base: dict[str, list[float]] = {}
for (ex, sym), rec in scan.items():
base = next(q.base for q in selected[ex] if q.symbol == sym)
if rec["last_close"] and rec["last_close"] > 0:
by_base.setdefault(base, []).append(rec["last_close"])
median_px = {b: statistics.median(v) for b, v in by_base.items()}
# 6) finalizza validazione
registry: dict = {"generated_at": datetime.now(timezone.utc).isoformat(),
"congruence_tol": CONGRUENCE_TOL, "testnet": True, "exchanges": {}}
for ex, ad in adapters.items():
registry["exchanges"][ex] = {"timeframes": tf_by_ex[ex], "instruments": {}}
for q in selected[ex]:
rec = scan[(ex, q.symbol)]
reasons = list(rec["reasons"])
px, med, n_src = rec["last_close"], median_px.get(q.base), len(by_base.get(q.base, []))
if not (rec["vol"] and rec["vol"] > 0):
reasons.append("no_volume")
if px is None or px <= 0:
if "no_history" not in reasons:
reasons.append("no_price")
elif med and n_src >= 2 and abs(px - med) / med > CONGRUENCE_TOL:
reasons.append(f"incongruent(px={px:.4g},med={med:.4g})")
valid = len(reasons) == 0
registry["exchanges"][ex]["instruments"][q.symbol] = {
"base": q.base, "valid": valid, "reasons": reasons,
"last_price": px, "start_date": rec["start_date"],
"timeframes": tf_by_ex[ex] if valid else [],
}
if save:
REGISTRY_PATH.write_text(json.dumps(registry, indent=2))
print(f" registry salvato in {REGISTRY_PATH}")
return registry
# --------------------------- gate per il downloader ---------------------------
def load_registry() -> dict:
return json.loads(REGISTRY_PATH.read_text()) if REGISTRY_PATH.exists() else {}
def is_validated(symbol: str, tf: str, exchange: str = "deribit") -> bool:
"""True solo se lo strumento e' nel registry come valido per quel timeframe."""
inst = load_registry().get("exchanges", {}).get(exchange, {}).get("instruments", {}).get(symbol)
return bool(inst and inst.get("valid") and tf in inst.get("timeframes", []))
if __name__ == "__main__":
reg = build_registry()
print("\n" + "=" * 96)
print(" REGISTRY STRUMENTI VALIDATI")
print("=" * 96)
for ex, exd in reg["exchanges"].items():
insts = exd["instruments"]
valid = {s: i for s, i in insts.items() if i["valid"]}
print(f"\n {ex.upper()} | tf={exd['timeframes']} | validi {len(valid)}/{len(insts)}")
for s, i in sorted(valid.items(), key=lambda kv: kv[1]["base"]):
print(f" {s:30s} {i['base']:10s} px={i['last_price']:<12.6g} dal {i['start_date']}")
bad = {s: i for s, i in insts.items() if not i["valid"]}
if bad:
shown = list(bad.items())[:6]
print(f" -- scartati {len(bad)} (primi {len(shown)}):")
for s, i in shown:
print(f" {s:30s} {','.join(i['reasons'])[:64]}")
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}"
View File
View File
-94
View File
@@ -1,94 +0,0 @@
"""Portfolio: definizione (sleeve + schema pesi) con faccia di backtest.
La faccia live è in runner.py."""
from __future__ import annotations
from dataclasses import dataclass, field
import pandas as pd
from src.portfolio import weighting as W
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from scripts.analysis.combine_portfolio import port_returns, metrics, yearly_returns, SPLIT
@dataclass
class SleeveSpec:
kind: str
name: str
sid: str
asset: str | None = None
a: str | None = None
b: str | None = None
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = ""
@dataclass
class PortfolioResult:
code: str
weights: dict
full: dict
oos: dict
yearly: dict
risk: dict
@dataclass
class Portfolio:
code: str
label: str
sleeves: list[SleeveSpec]
weighting: str = "equal"
weights: dict | None = None
caps: dict | None = None
total_capital: float = 1000.0
leverage: float = 3.0
rebalance: str = "1D"
vol_lookback: int = 90
@property
def sleeve_ids(self) -> list[str]:
return [s.sid for s in self.sleeves]
@property
def clusters(self) -> dict[str, str]:
return {s.sid: (s.cluster or s.sid) for s in self.sleeves}
def weight_vector(self, returns_df: pd.DataFrame | None = None) -> dict[str, float]:
return W.weight_vector(
self.weighting, self.sleeve_ids, returns_df,
weights=self.weights, caps=self.caps,
clusters=self.clusters, lookback=self.vol_lookback,
)
def backtest(self) -> PortfolioResult:
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in self.sleeve_ids}
dr = sleeve_returns_df(self.sleeve_ids)
w = self.weight_vector(dr)
port_dr = port_returns(members, w)
full, oos = metrics(port_dr), metrics(port_dr, lo=SPLIT)
import numpy as np
we = np.ones(len(self.sleeve_ids)) / len(self.sleeve_ids)
cov = dr.cov().values
pv = float(we @ cov @ we)
rc = we * (cov @ we)
risk = {sid: float(rc[k] / pv * 100) if pv > 0 else 0.0
for k, sid in enumerate(self.sleeve_ids)}
return PortfolioResult(self.code, w, full, oos, yearly_returns(port_dr), risk)
def load_active_portfolio(config_path) -> "Portfolio":
"""Carica il portafoglio attivo da portfolios.yml applicando gli override."""
import yaml
from pathlib import Path
from scripts.portfolios._defs import PORTFOLIOS
cfg = yaml.safe_load(Path(config_path).read_text())
p = PORTFOLIOS[cfg["active"]]
ov = cfg.get("overrides", {})
for k in ("total_capital", "weighting", "caps", "leverage", "rebalance", "vol_lookback"):
if k in ov and ov[k] is not None:
setattr(p, k, ov[k])
return p
-92
View File
@@ -1,92 +0,0 @@
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
class PortfolioLedger:
def __init__(self, code: str, total_capital: float = 1000.0,
data_dir: Path = Path("data/portfolios")):
self.code = code
self.initial_capital = total_capital
self.total_capital = total_capital
self.work_dir = Path(data_dir) / code
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.equity_path = self.work_dir / "equity.jsonl"
self.events_path = self.work_dir / "events.jsonl"
self.equity = total_capital
self.peak = total_capital
self.max_dd = 0.0
self.weights: dict[str, float] = {}
self.alloc: dict[str, float] = {}
self.last_rebalance = ""
self._load()
def _load(self):
if not self.status_path.exists():
return
s = json.loads(self.status_path.read_text())
self.total_capital = s.get("total_capital", self.total_capital)
self.equity = s.get("equity", self.equity)
self.peak = s.get("peak", self.peak)
self.max_dd = s.get("max_dd", self.max_dd)
self.weights = s.get("weights", {})
self.alloc = s.get("alloc", {})
self.last_rebalance = s.get("last_rebalance", "")
def allocate(self, weights: dict[str, float],
reserved: dict[str, float] | None = None) -> dict[str, float]:
"""alloc per sid = peso × total_capital. `reserved` {sid: capitale trattenuto}
per i worker con posizione APERTA: quel capitale e' deployato, non
ridistribuibile -> i flat si dividono (total Σreserved) per peso
RINORMALIZZATO sui soli flat. Cosi' Σalloc == total_capital ed equity e'
CONSERVATA dal ribilancio. Senza reserved (default): comportamento storico
(alloc = peso×total per tutti), corretto solo se tutti i worker sono flat
(allocazione iniziale). Fix 2026-06-13: prima i flat si dividevano l'INTERO
total includendo il capitale degli in-position -> doppio conteggio, equity
gonfiata di Σ(capital_in_pos alloc_in_pos) (caso MR02_BTC 15m seedato e in
posizione al ribilancio: +4.77). Vedi docs/diary/2026-06-13-rebalance-conservation.md."""
self.weights = dict(weights)
reserved = reserved or {}
distributable = self.total_capital - sum(reserved.values())
flat = {sid: w for sid, w in weights.items() if sid not in reserved}
wsum = sum(flat.values())
self.alloc = {sid: round(cap, 6) for sid, cap in reserved.items()}
for sid, w in flat.items():
share = (w / wsum) if wsum > 0 else (1.0 / len(flat) if flat else 0.0)
self.alloc[sid] = round(distributable * share, 6)
self.last_rebalance = datetime.now(timezone.utc).isoformat()
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
"total_capital": self.total_capital,
"reserved": sorted(reserved)})
return self.alloc
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
self.equity = float(sum(sleeve_equity.values()))
if self.equity > self.peak:
self.peak = self.equity
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
self.max_dd = max(self.max_dd, dd)
self._append(self.equity_path, {
"ts": datetime.now(timezone.utc).isoformat(),
"equity": round(self.equity, 2), "dd": round(dd, 3),
"pnl_day": round(pnl_day, 2),
"pnl_total": round(self.equity - self.initial_capital, 2),
})
def save(self):
self.status_path.write_text(json.dumps({
"code": self.code, "total_capital": round(self.total_capital, 2),
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
"max_dd": round(self.max_dd, 3), "weights": self.weights,
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
"ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
@staticmethod
def _append(path: Path, row: dict):
with open(path, "a") as f:
f.write(json.dumps(row) + "\n")
-701
View File
@@ -1,701 +0,0 @@
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
Riusa i worker esistenti come esecutori e il data layer Cerbero v2.
Worker per tipo di sleeve:
single (fade/dip) -> StrategyWorker | ml (shape, SH01) -> StrategyWorker (WF interno)
pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker
rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker
Feed: il runner fetcha candele 1h da Cerbero v2 e le RESAMPLA a 4h/1d (come get_df nel
backtest) per i worker a cadenza piu' lenta. Il lookback per asset e' dimensionato sul
worker piu' esigente (TSM01 usa 252 giorni)."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from src.portfolio.base import SleeveSpec, Portfolio
from src.portfolio.ledger import PortfolioLedger
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker
from src.live.xsec_worker import CrossSectionalWorker
from src.live.grid_worker import GridWorker
from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
_STRAT_MODULE = {
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
"DIP01": "DIP01_dip_buy",
}
_MULTI_KINDS = ("basket", "rotation", "tsmom", "xsec")
DATA_DIR = Path("data/portfolio_paper")
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
_LOOKBACK_DAYS = {"5m": 7, "15m": 14, "30m": 21, "1h": 90, "4h": 220, "1d": 440}
# timeframe SUB-orari: si fetchano DIRETTI da Cerbero (non resamplabili dal 1h).
_SUBHOURLY = {"5m", "15m", "30m"}
# SH01 (ml) richiede >=4000 barre 1h (train_min di ml_wf_entries); 365g (~8760 barre) danno
# margine ampio per il walk-forward. Difensivo: non dipende dal fetch 440g di TSM01/ROT02.
_ML_LOOKBACK_DAYS = 365
def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float],
sleeve_ps: float | None = None) -> float:
"""position_size effettivo di uno sleeve. Precedenza: override PER-SLEEVE
(spec.params['position_size'], es. il 15m a 0.10) > override per-FAMIGLIA
(weighting.family_of: PAIRS/FADE/...) > globale."""
from src.portfolio.weighting import family_of
if sleeve_ps is not None:
return float(sleeve_ps)
return family_overrides.get(family_of(sid), global_ps)
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15,
executor=None, exec_instrument: str | None = None,
pairs_executor=None, exec_instruments: dict | None = None,
real_truth: bool = False):
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
(esecuzione reale a 2 gambe). real_truth: il ledger `capital` si aggiorna col PnL
dei FILL REALI (sim ridotto a diagnostica) — inerte senza executor."""
if spec.kind == "pairs":
return PairsWorker(
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
capital=alloc_capital, position_size=position_size, leverage=leverage,
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
executor=pairs_executor, exec_instruments=exec_instruments,
real_truth=real_truth,
)
if spec.kind == "basket":
pr = spec.params
return BasketTrendWorker(
universe=pr["universe"], tf=pr.get("tf", "4h"), capital=alloc_capital,
position_size=position_size, leverage=leverage, data_dir=data_dir,
)
if spec.kind == "rotation":
pr = spec.params
return RotationWorker(
universe=pr["universe"], top_k=pr.get("top_k", 3), gross=pr.get("gross", 0.45),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
if spec.kind == "tsmom":
pr = spec.params
return TsmomWorker(
universe=pr["universe"], horizons=tuple(pr.get("horizons", (63, 126, 252))),
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
if spec.kind == "xsec":
pr = spec.params
return CrossSectionalWorker(
universe=pr["universe"], tf=pr.get("tf", "1h"),
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
"disp_min": pr.get("disp_min"),
"tranches": pr.get("tranches", 1)},
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)
if spec.kind == "grid":
# Price Ladder (griglia) — SIM/PAPER (shadow stage 1): nessun ordine reale.
return GridWorker(
sid=spec.sid, asset=spec.asset, params=spec.params, capital=alloc_capital,
work_dir=Path(data_dir) / spec.sid, leverage=leverage,
position_size=position_size, fee_side=0.0005,
)
module = _STRAT_MODULE.get(spec.name)
if module is None:
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
strategy = load_strategy(module)
# SH01 (kind="ml") gira come StrategyWorker NORMALE: SH01_shape_ml.generate_signals fa il
# walk-forward (retraining) internamente ad ogni tick ed emette metadata.max_bars=H -> gli
# exit passano per StrategyWorker.tick (orizzonte H). NON usare il vecchio MLWorkerWrapper di
# multi_runner: quello usa SignalEngine (famiglia squeeze SCARTATA), apre senza metadata ed
# esce a hold_bars=3, ignorando del tutto SH01_shape_ml. Serve >=4000 barre 1h (train_min):
# garantite da _ML_LOOKBACK_DAYS.
return StrategyWorker(
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
executor=executor, exec_instrument=exec_instrument, real_truth=real_truth,
)
def _worker_equity(w) -> float:
inner = getattr(w, "worker", w) # smonta MLWorkerWrapper
return float(getattr(inner, "capital", 0.0))
def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[str, float]):
"""Ribilancio: total_capital = Σ equity sleeve; riallinea il capitale-base di ogni worker
a peso×total. I worker con posizione APERTA NON vengono ritoccati (la posizione mantiene
il suo notional, come da approssimazione dichiarata): il nuovo capitale-base si applica
alla prossima posizione, quando il worker è flat."""
ledger.total_capital = sum(_worker_equity(w) for w in workers.values())
# i worker in posizione TRATTENGONO il loro capitale (deployato): vanno passati
# come `reserved` ad allocate, cosi' i flat si dividono solo il RESTO e l'equity
# totale e' conservata (fix doppio conteggio 2026-06-13, vedi ledger.allocate).
reserved = {sid: _worker_equity(w) for sid, w in workers.items()
if getattr(getattr(w, "worker", w), "in_position", False)}
alloc = ledger.allocate(weights, reserved=reserved)
for sid, w in workers.items():
inner = getattr(w, "worker", w)
if getattr(inner, "in_position", False):
continue
inner.capital = alloc.get(sid, inner.capital)
ledger.save()
_OHLCV = ["timestamp", "open", "high", "low", "close", "volume"]
def _with_history(hist: pd.DataFrame | None, live: pd.DataFrame,
warned: set | None = None, asset: str = "") -> pd.DataFrame:
"""Bootstrap storia per SH01 (punto-10, 2026-06-07): parquet locale PRIMA del
feed live (dedup sul timestamp). La ri-validazione ha mostrato che l'edge SH01
richiede il training EXPANDING full-history: col solo lookback live (365g) la
LogReg e' over-confident e la strategia NON e' robusta. Se c'e' un gap fra
parquet e feed (parquet stantio oltre il lookback) si usa il SOLO feed con
WARN: meglio il regime corto dichiarato che una serie con un buco."""
if hist is None or live is None or live.empty:
return live
lo = int(live["timestamp"].iloc[0])
h = hist[hist["timestamp"] < lo]
if h.empty:
return live
if int(h["timestamp"].iloc[-1]) < lo - 2 * 3_600_000: # buco > 1 barra 1h
if warned is not None and asset not in warned:
warned.add(asset)
print(f"[runner] WARN: gap fra parquet e feed live per {asset} "
f"(parquet stantio? rilanciare download_all) — SH01 senza bootstrap")
return live
return pd.concat([h[_OHLCV], live[_OHLCV]], ignore_index=True)
def _resample(df: pd.DataFrame, tf: str) -> pd.DataFrame:
"""Resampla candele 1h -> 4h/1d mantenendo timestamp ms reale (come get_df del backtest)."""
if tf == "1h":
return df
rule = {"4h": "4h", "1d": "1D"}[tf]
d = df.copy()
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.set_index("dt")
agg = d.resample(rule).agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"}).dropna()
epoch = pd.Timestamp("1970-01-01", tz="UTC")
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return agg.reset_index(drop=True)
def _spec_assets_tf(spec: SleeveSpec):
"""(lista asset, tf) coinvolti da uno sleeve."""
if spec.kind == "pairs":
return [spec.a, spec.b], spec.tf
if spec.kind in _MULTI_KINDS:
return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h")
if spec.kind == "grid":
return [spec.asset], spec.params.get("tf", spec.tf)
return [spec.asset], spec.tf
_STALE_BARS = 2 # barre 1h COMPLETE consecutive flat (O=H=L=C) -> feed fermo
def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
"""Osservabilita' (2026-06-05): alert Telegram quando il feed e' flat/fermo da
>= _STALE_BARS barre 1h complete (i worker sono ciechi: il prossimo prezzo reale
puo' gappare attraverso TP/SL, come ETH flat 13:00-14:50 -> gap 1655->1600) e al
risveglio (con il gap % del primo prezzo reale). Una notifica per episodio.
NB: SOLO osservabilita' — saltare gli ingressi post-flat PEGGIORA l'edge
(testato: la candela-gap e' l'overshoot che la fade fada con profitto)."""
from src.live.bars import last_settled_idx
from src.live.telegram_notifier import notify_event
if len(df) < _STALE_BARS + 2:
return
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
# ultima barra COMPLETA (detection condivisa src.live.bars; prima hardcodava 1h)
k = last_settled_idx(df["timestamp"].values)
i = len(c) + k
if i < 1:
return
flat = (o == h) & (h == l) & (l == c)
n_flat = 0
while i - n_flat >= 0 and flat[i - n_flat]:
n_flat += 1
if n_flat >= _STALE_BARS and asset not in alerted:
alerted.add(asset)
notify_event("STALE_FEED", {"asset": asset, "flat_bars_1h": n_flat,
"ultimo_prezzo": float(c[i])})
elif n_flat == 0 and asset in alerted:
alerted.discard(asset)
gap = (c[i] / c[i - 1] - 1) * 100 if c[i - 1] else 0.0
notify_event("STALE_FEED", {"asset": asset, "status": "RIPRESO",
"gap_pct": round(gap, 2), "prezzo": float(c[i])})
# --- Gate feed CONGELATO (2026-06-15) -----------------------------------------
# Distinto da STALE_FEED (osservabilita') e FEED_BOOK_GAP (divergenza esecuzione):
# qui si AGISCE. Quando il feed di DECISIONE 1h di un asset e' CONGELATO (guasto
# testnet: ETH-PERPETUAL inverse fermo a 1661.95 per 36h+, prezzo MAI cambiato) gli
# sleeve CONCENTRATI che ne dipendono (single/ml/pairs) decidono entry/exit su un
# prezzo morto -> segnali spuri (z-score pairs estremi -3/-5, SH01 short a prezzo
# fermo) e perdite REALI (i fill avvengono al book vero ~1717, l'uscita sim al prezzo
# congelato: SH01_ETH ha realizzato -2.83 reali vs -0.09 sim su un solo close). Si
# SALTA il tick (entry E exit) finche' il feed non si sblocca: come un outage (i worker
# non valutano gli exit, protezione = disaster-SL on-book), auto-guarente.
#
# DISTINGUERE guasto da ILLIQUIDITA': SOL/LTC/ADA stampano molte barre flat (O=H=L=C,
# 50-95%) ma il prezzo SI muove ogni poche ore (run di close identiche CORTE, ~2-5). Il
# guasto e' il prezzo che non cambia MAI: run di close INVARIATE >= soglia (il freeze
# reale dura decine di barre). Un detector flat-bar ingenuo gaterebbe gli alt
# illiquidi-ma-VIVI -> si conta la run di close invariate, NON le barre flat.
#
# NB POST-FLAT: si gatea DURANTE il freeze (ultima barra completa ferma). La barra di
# RIPRESA e' non-flat -> la run si azzera -> il tick riprende SU di essa. NON e'
# l'entry-guard post-flat (BOCCIATA: la candela-gap e' l'overshoot che la fade fada con
# profitto, CLAUDE.md / 2026-06-05): quello salta la barra di ripresa, questo no.
#
# SOGLIA (misurata sul feed reale 2026-06-15): le due popolazioni sono ben separate ->
# MORTO ETH run 64 (1 val distinto/48h), BNB 64, DOGE 42 (feed pinnato a un valore)
# ILLIQUIDO LTC run 10, ADA 11, XRP 12, SOL 1 (5-31 val distinti/48h: SI muovono)
# A 24 (un giorno intero di prezzo immobile) il guasto multi-giorno e' preso con ampio
# margine senza gateare gli alt illiquidi-ma-VIVI (importante per PR_BTCLTC: BTC vivo +
# LTC solo illiquido NON deve sospendere il pair). ETH (run 64) gatea comunque subito.
_FREEZE_GATE_BARS = 24 # run di close INVARIATE sull'ultima barra completa = freeze
def _frozen_assets(raw1h: dict[str, pd.DataFrame], threshold: int) -> set[str]:
"""Asset col feed di decisione 1h CONGELATO: ultima barra completa flat (O=H=L=C)
E close invariata da >= `threshold` barre complete consecutive. 0 = gate disattivo."""
from src.live.bars import last_settled_idx
frozen: set[str] = set()
if threshold <= 0:
return frozen
for asset, df in raw1h.items():
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
i = len(c) + last_settled_idx(df["timestamp"].values)
if i < 1 or not (o[i] == h[i] == l[i] == c[i]):
continue # ultima barra completa non flat -> vivo
run = 1
while i - run >= 0 and c[i - run] == c[i]:
run += 1
if run >= threshold:
frozen.add(asset)
return frozen
def _feed_gated_sids(live_specs, frozen: set[str]) -> set[str]:
"""sid degli sleeve CONCENTRATI (single/ml/pairs) che dipendono da un asset col feed
congelato. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER) NON sono gateati:
diversificati su 8 asset, un singolo feed fermo non li compromette."""
if not frozen:
return set()
return {s.sid for s in live_specs
if s.kind in ("single", "ml", "pairs")
and any(a in frozen for a in _spec_assets_tf(s)[0])}
_GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile
def _check_feed_book_gap(client, raw1h, instruments, threshold_bps, alerted):
"""Osservabilita' (2026-06-12): il feed candele e il book dove fillano gli ordini
REALI possono divergere — caso MR02_BTC: TP resting fillato a 60481 nella notte
col feed mai sceso sotto 63285 (-443 bps, scoperto solo al close sim); i wick
TP_PHANTOM sono il caso opposto (feed stampa, book non scambia). Confronta il
close della candela in corso col MARK dello strumento d'ESECUZIONE (USDC):
oltre soglia -> alert FEED_BOOK_GAP, una notifica per episodio, recovery con
isteresi a soglia/2. Le decisioni restano sul feed (il sim e' la verita' che
guida): questo dice solo QUANDO i fill reali possono divergere dal sim."""
from src.live.telegram_notifier import notify_event
want = {a: inst for a, inst in instruments.items() if a in raw1h}
if not want:
return
try:
out = client.get_ticker_batch(list(want.values()))
marks = {t.get("instrument_name"): (t.get("mark_price") or t.get("last_price"))
for t in out.get("tickers", [])}
except Exception:
return # fail-open: solo osservabilita'
for asset, inst in want.items():
mark = marks.get(inst)
feed = float(raw1h[asset]["close"].iloc[-1])
if not mark or not feed:
continue
gap_bps = abs(feed / float(mark) - 1) * 10_000
if gap_bps >= threshold_bps and asset not in alerted:
alerted.add(asset)
print(f"[runner] FEED_BOOK_GAP {asset}: feed {feed} vs mark {mark} "
f"({gap_bps:.0f} bps)")
notify_event("FEED_BOOK_GAP", {
"asset": asset, "feed_close": feed, "mark_book": float(mark),
"gap_bps": round(gap_bps, 1),
"nota": "feed candele != book d'esecuzione: i fill reali possono "
"divergere dal sim (TP fantasma / fill non visti dal feed)"})
elif gap_bps < threshold_bps / 2 and asset in alerted:
alerted.discard(asset)
notify_event("FEED_BOOK_GAP", {"asset": asset, "status": "RIENTRATO",
"gap_bps": round(gap_bps, 1)})
def run(config_path: str = "portfolios.yml"):
"""Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample;
ribilancio a cambio giornata UTC."""
import time
from datetime import datetime, timezone, timedelta
import yaml
from src.portfolio.base import load_active_portfolio
from src.portfolio.sleeves import sleeve_returns_df
from src.portfolio import weighting as W
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
p: Portfolio = load_active_portfolio(config_path)
_ov = (yaml.safe_load(Path(config_path).read_text()) or {}).get("overrides", {})
poll = int(_ov.get("poll_seconds", 60))
# Frazione di capitale-sleeve impegnata per posizione (default 0.15 = canonico backtest).
# Con leva 2x: notional = capital * position_size * 2. A 0.5 ogni sleeve in posizione
# impegna il 100% della sua fetta (max impiego senza debito di margine); DD scala ~lineare.
position_size = float(_ov.get("position_size", 0.15))
# Override PER-FAMIGLIA (improvement-sweep punto 8): la chiave e' la famiglia di
# weighting.family_of (PAIRS/FADE/HONEST/SHAPE/TSM). Nato per i pairs: tutta la
# validazione PR01 e' a pos 0.15 e la famiglia e' SENZA stop -> il pos globale 0.5
# la faceva girare a ~2.2x l'esposizione validata. Gate: pairspos_port06_impact.py.
ps_family = {str(k).upper(): float(v)
for k, v in (_ov.get("position_size_family") or {}).items()}
def _supported(s):
return s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE
supported = [s for s in p.sleeves if _supported(s)]
skipped = [s.sid for s in p.sleeves if not _supported(s)]
if skipped:
print(f"[runner] sleeve saltati nel live (worker non disponibili): {skipped}")
# SLEEVE "PAPER" (solo statistica, 2026-06-08): NON entrano nel pool/pesi/ledger del
# portafoglio — i €total_capital si dividono SOLO tra gli sleeve reali. I paper girano
# con un capitale nozionale fisso (la fetta equal che avrebbero avuto) per raccogliere
# statistica in vista di future implementazioni reali. Default: TR01/ROT02/TSM01
# (multi-asset, esecuzione reale bloccata dal capitale).
paper_codes = {str(c).upper() for c in (_ov.get("paper_sleeves") or [])}
live_specs = [s for s in supported if s.name.upper() not in paper_codes]
paper_specs = [s for s in supported if s.name.upper() in paper_codes]
# PAPER_EXTRA: sleeve paper definiti SOLO in config (NON in p.sleeves) -> NON entrano
# nel backtest canonico/regression-lock (all_sleeve_equities non sa costruirli). Nato
# per il Price Ladder (kind=grid, shadow stage 1 sim). Parsing DIFENSIVO: un errore qui
# non deve crashare il runner mainnet.
for _ex in (_ov.get("paper_extra") or []):
try:
paper_specs.append(SleeveSpec(
kind=str(_ex["kind"]), name=str(_ex.get("name", _ex["sid"])),
sid=str(_ex["sid"]), asset=_ex.get("asset"),
tf=str(_ex.get("tf", "1h")), params=dict(_ex.get("params", {})),
cluster=str(_ex.get("cluster", "")),
))
except Exception as e:
print(f"[runner] WARN paper_extra ignorato ({_ex}): {e}")
if paper_specs:
print(f"[runner] sleeve PAPER (solo statistica, fuori dal pool): "
f"{[s.sid for s in paper_specs]}")
live_ids = [s.sid for s in live_specs]
clusters = {s.sid: (s.cluster or s.sid) for s in live_specs}
paper_notional = p.total_capital / max(len(supported), 1) # fetta equal nozionale
ledger = PortfolioLedger(p.code, total_capital=p.total_capital)
client = CerberoClient()
# --- Esecuzione REALE (shadow) su Deribit testnet, solo sui fade abilitati ---
# overrides.execution: {enabled, sleeves:[MR01,...], instruments:{BTC:..,ETH:..}}
_exec_cfg = _ov.get("execution", {}) or {}
exec_enabled = bool(_exec_cfg.get("enabled"))
exec_sleeves = set(_exec_cfg.get("sleeves", []))
exec_instr = _exec_cfg.get("instruments", {}) or {}
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
# REAL-TRUTH (2026-06-10): il capitale degli sleeve eseguiti si aggiorna col PnL
# dei fill reali (sim = diagnostica). Default False: va acceso esplicitamente in yml.
real_truth = bool(_exec_cfg.get("real_truth", False))
executor = None
pairs_executor = None
if exec_enabled:
from src.live.execution import ExecutionClient
executor = ExecutionClient(client=client)
# disaster-bracket on-book (~-30%): assicurazione outage sui fade reali
executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct} "
f"real_truth={real_truth}")
if pairs_exec_enabled:
from src.live.execution import PairsExecutionClient
pairs_executor = PairsExecutionClient(leg=executor)
print(f"[runner] ESECUZIONE REALE PAIRS (2 gambe) attiva — strumenti={exec_instr}")
def _exec_for(s):
"""(executor, exec_instrument) per uno sleeve single-leg ABILITATO. Kind:
'single' (fade/DIP01) e 'ml' (SH01). SH01 non ha TP/SL -> _place_real_tp
ritorna subito e _real_close chiude tutto a market reduce-only (orizzonte):
infrastruttura gia' presente. Il disaster-bracket on-book resta l'unica
protezione di coda di SH01 durante un outage (esce a H=12 ben prima del -30%)."""
if not exec_enabled or s.kind not in ("single", "ml") or s.name not in exec_sleeves:
return None, None
return executor, exec_instr.get(s.asset)
def _pairs_exec_for(s):
"""(pairs_executor, {asset: instrument}) per uno sleeve pairs, se abilitato."""
if not pairs_exec_enabled or s.kind != "pairs":
return None, None
return pairs_executor, exec_instr
dr = sleeve_returns_df(live_ids)
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
alloc = ledger.allocate(weights)
workers = {}
for s in live_specs:
ex, inst = _exec_for(s)
pex, pinst = _pairs_exec_for(s)
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")),
executor=ex, exec_instrument=inst,
pairs_executor=pex, exec_instruments=pinst,
real_truth=real_truth)
if ps_family:
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
# worker PAPER (solo statistica): capitale nozionale fisso, NESSUNA esecuzione reale,
# NON nel ledger del portafoglio. Salvano in data/portfolio_paper_stats/.
paper_dir = DATA_DIR.parent / "portfolio_paper_stats"
paper_workers = {s.sid: build_worker_for(s, paper_notional, p.leverage,
data_dir=paper_dir,
position_size=pos_for_spec(s.sid, position_size, ps_family, s.params.get("position_size")))
for s in paper_specs}
# bootstrap storia full per gli sleeve ML (SH01): parquet locale + feed live.
# L'edge SH01 richiede train expanding full-history (sh01_trainwindow_validate);
# il path live fitta solo l'ultimo blocco (last_block_only nei params SHAPE).
ml_hist: dict[str, pd.DataFrame] = {}
ml_gap_warned: set[str] = set()
for a in sorted({s.asset for s in live_specs if s.kind == "ml"}):
try:
from src.data.downloader import load_data
ml_hist[a] = load_data(a, "1h")
last = pd.to_datetime(ml_hist[a]["timestamp"].iloc[-1], unit="ms", utc=True)
print(f"[runner] bootstrap storia SH01 {a}: {len(ml_hist[a])} barre parquet "
f"(fino a {last:%Y-%m-%d %H:%M})")
except Exception as e:
print(f"[runner] WARN bootstrap storia {a} fallito: {e} — SH01 col solo feed")
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {}
for s in live_specs + paper_specs: # live + PAPER (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
days = _LOOKBACK_DAYS.get(tf, 90)
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
days = max(days, _ML_LOOKBACK_DAYS)
for a in assets:
asset_days[a] = max(asset_days.get(a, 0), days)
# timeframe SUB-orari (es. pairs 15m, flat-skip): non resamplabili dal 1h ->
# fetch DIRETTO da Cerbero per (asset, tf). Inerte se nessuno sleeve e' sub-orario.
subhourly_needs: dict[tuple[str, str], int] = {}
for s in live_specs + paper_specs: # live + paper (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
if tf in _SUBHOURLY:
for a in assets:
subhourly_needs[(a, tf)] = max(subhourly_needs.get((a, tf), 0),
_LOOKBACK_DAYS.get(tf, 14))
if subhourly_needs:
print(f"[runner] timeframe sub-orari (fetch diretto Cerbero): {sorted(subhourly_needs)}")
inst_map = dict(INSTRUMENT_MAP)
last_day = ""
stale_alerted: set[str] = set() # asset con alert STALE_FEED attivo (dedup per episodio)
# guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off)
gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT))
gap_alerted: set[str] = set()
# gate feed CONGELATO (2026-06-15): salta gli sleeve concentrati su un asset col feed
# fermo. Soglia (barre 1h di close invariata) in overrides.feed_freeze_gate_bars (0 = off).
freeze_gate_bars = int(_ov.get("feed_freeze_gate_bars", _FREEZE_GATE_BARS))
spec_by_id = {s.sid: s for s in live_specs}
frozen_gated: set[str] = set() # sleeve gateati ora (dedup alert + recovery)
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
# "Degradato" include il caso HTTP-200-con-candles-vuote (code review 2026-06-07):
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
_OUTAGE_POLLS = 5
fail_streak = 0
worker_err_streak: dict[str, int] = {} # errori consecutivi per worker (isolamento tick)
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
volta per episodio; RIPRESO al primo poll pulito). Ritorna il nuovo streak."""
from src.live.telegram_notifier import notify_event
if failed:
streak += 1
if streak == _OUTAGE_POLLS:
real_open = sorted(sid for sid, wk in workers.items()
if getattr(wk, "real_in_position", False))
notify_event("FEED_OUTAGE", {
"poll_falliti": streak,
"minuti": round(streak * poll / 60),
"dettaglio": detail,
"posizioni_reali_aperte": ", ".join(real_open) or "nessuna",
"nota": "exit NON valutati durante l'outage; "
"protezione = disaster-SL on-book sui fade reali"})
return streak
if streak >= _OUTAGE_POLLS:
notify_event("FEED_OUTAGE", {"status": "RIPRESO",
"poll_falliti": streak,
"minuti": round(streak * poll / 60)})
return 0
while True:
try:
# fetch 1h per asset al lookback massimo richiesto
raw1h: dict[str, pd.DataFrame] = {}
end = datetime.now(timezone.utc)
# SOLO testnet (via Cerbero): il paper DEVE usare lo stesso venue dove gli ordini
# verrebbero eseguiti (testnet). Mai sostituire con dati mainnet -> divergerebbe dal
# comportamento reale (prezzi/liquidità testnet != mainnet). Durante un outage testnet
# il runner si mette in pausa (corretto: senza il venue non si potrebbe eseguire).
for asset, days in asset_days.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), "1h")
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
_check_stale_feed(asset, raw1h[asset], stale_alerted)
if exec_enabled and gap_bps > 0:
_check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted)
# gate feed CONGELATO: sleeve concentrati su un asset col feed fermo -> tick saltato
frozen = _frozen_assets(raw1h, freeze_gate_bars)
now_gated = _feed_gated_sids(live_specs, frozen)
if now_gated != frozen_gated:
from src.live.telegram_notifier import notify_event
for sid in sorted(now_gated - frozen_gated):
fa = sorted(set(_spec_assets_tf(spec_by_id[sid])[0]) & frozen)
print(f"[runner] FEED_FROZEN_GATE {sid}: asset congelati {fa} -> tick sospeso")
notify_event("FEED_FROZEN_GATE", {
"sleeve": sid, "asset_congelati": ", ".join(fa), "stato": "GATED",
"nota": "feed di decisione fermo: entry+exit sospesi finche' non si "
"sblocca (disaster-SL on-book protegge le posizioni reali)"})
for sid in sorted(frozen_gated - now_gated):
print(f"[runner] FEED_FROZEN_GATE {sid}: feed ripreso -> tick riattivato")
notify_event("FEED_FROZEN_GATE", {"sleeve": sid, "stato": "RIPRESO"})
frozen_gated = now_gated
# fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), tf)
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
raw_sub[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
def _series_for(a, tf):
"""Serie OHLC per (asset, tf): diretta se sub-oraria, altrimenti resample dal 1h."""
if tf in _SUBHOURLY:
return raw_sub.get((a, tf))
return _resample(raw1h[a], tf) if a in raw1h else None
# tick di ogni worker col suo timeframe
def _tick(s, w):
assets, tf = _spec_assets_tf(s)
res = {a: _series_for(a, tf) for a in assets}
if any(res[a] is None or len(res[a]) == 0 for a in assets):
return
if s.kind == "pairs":
w.tick(res[s.a], res[s.b])
elif s.kind in _MULTI_KINDS:
w.tick(res)
elif s.kind == "ml":
# SH01: storia full (parquet bootstrap + feed) -> il walk-forward
# interno fitta solo l'ultimo blocco (last_block_only nei params).
w.tick(_with_history(ml_hist.get(s.asset), res[s.asset],
ml_gap_warned, s.asset))
elif s.kind == "grid":
# Price Ladder SIM/PAPER: ricomputa la griglia sul feed live (BTC 1h).
w.tick(res[s.asset])
else:
# single (fade/dip): StrategyWorker su feed live.
w.tick(res[s.asset])
# isolamento per-worker (audit 2026-06-11): un'eccezione in un tick NON
# deve saltare gli altri worker (= exit non valutati per tutti) ne'
# l'update dell'equity. Streak per-worker -> alert dopo 5 fail di fila.
def _tick_safe(s, w):
try:
_tick(s, w)
n_prev = worker_err_streak.pop(s.sid, 0)
if n_prev >= 5:
# recovery dichiarato (code-review: alert one-shot senza
# ripresa = silenzio indistinguibile dal guasto)
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK",
{"worker": s.sid, "status": "RIPRESO",
"dopo_streak": n_prev})
except Exception as e:
n = worker_err_streak.get(s.sid, 0) + 1
worker_err_streak[s.sid] = n
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)")
# alert a 5 e poi ogni 50 poll (~1h): un worker rotto per
# giorni non deve sparire dopo il primo Telegram. Include
# se ha una posizione REALE aperta (exit non valutati!)
if n == 5 or n % 50 == 0:
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK", {
"worker": s.sid, "streak": n, "errore": str(e)[:200],
"real_in_position": bool(getattr(w, "real_in_position", False)),
"in_position": bool(getattr(w, "in_position", False)),
"nota": "exit NON valutati per questo worker"})
for s in live_specs:
if s.sid in now_gated:
continue # feed di decisione congelato -> entry+exit sospesi
_tick_safe(s, workers[s.sid])
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
for s in paper_specs:
_tick_safe(s, paper_workers[s.sid])
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if today != last_day and last_day:
dr = sleeve_returns_df(live_ids)
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
rebalance_allocations(ledger, workers, weights)
last_day = today
ledger.save()
# feed degradato senza eccezione: asset richiesti ma senza candele
missing = sorted(a for a in asset_days if a not in raw1h)
if missing:
print(f"[runner] feed incompleto: mancano {missing} (streak {fail_streak + 1})")
fail_streak = _outage_tick(bool(missing), fail_streak,
detail=f"feed senza candele per: {', '.join(missing)}")
except KeyboardInterrupt:
ledger.save()
print("shutdown")
break
except Exception as e:
print(f"[runner] errore: {e} (streak {fail_streak + 1})")
fail_streak = _outage_tick(True, fail_streak, detail=f"eccezione: {e}")
time.sleep(poll)
if __name__ == "__main__":
run()
-26
View File
@@ -1,26 +0,0 @@
"""Unico builder delle equity GIORNALIERE per sleeve (fonte di verità del backtest).
Delega a scripts/analysis/report_families.build_everything (che a sua volta usa
combine_portfolio + pairs_research + tsmom_research + shape_ml_validate), così le
metriche del Portfolio coincidono per costruzione con report_families."""
from __future__ import annotations
import pandas as pd
_CACHE: dict[str, pd.Series] | None = None
def all_sleeve_equities() -> dict[str, pd.Series]:
"""{sleeve_id: equity giornaliera normalizzata su IDX comune}. Cache di processo."""
global _CACHE
if _CACHE is None:
from scripts.analysis.report_families import build_everything
S, pairs, tsm, shape = build_everything()
_CACHE = {**S, **pairs, **tsm, **shape}
return _CACHE
def sleeve_returns_df(ids: list[str]) -> pd.DataFrame:
"""Rendimenti giornalieri allineati per gli sleeve richiesti."""
eq = all_sleeve_equities()
return pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
-96
View File
@@ -1,96 +0,0 @@
"""Schemi di peso per i portafogli. Ogni funzione ritorna {sleeve_id: peso} con somma 1."""
from __future__ import annotations
import numpy as np
import pandas as pd
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE"), ("XS", "XSEC")]
def family_of(sleeve_id: str) -> str:
for pre, fam in _PREFIX:
if sleeve_id.startswith(pre):
return fam
return "HONEST"
def _normalize(w: dict[str, float]) -> dict[str, float]:
tot = sum(w.values())
return {k: (v / tot if tot > 0 else 0.0) for k, v in w.items()}
def equal(ids: list[str]) -> dict[str, float]:
n = len(ids)
return {i: 1.0 / n for i in ids} if n else {}
def manual(ids: list[str], weights: dict[str, float]) -> dict[str, float]:
return _normalize({i: float(weights.get(i, 0.0)) for i in ids})
def cap(ids: list[str], caps: dict[str, float]) -> dict[str, float]:
"""Equal-weight con tetto al peso AGGREGATO di una famiglia; l'eccesso ridistribuito
pro-quota alle famiglie non cappate (iterativo finché tutti i cap sono rispettati)."""
w = equal(ids)
fam = {i: family_of(i) for i in ids}
for _ in range(10):
over = {}
for f, lim in caps.items():
members = [i for i in ids if fam[i] == f]
cur = sum(w[i] for i in members)
if cur > lim + 1e-12 and members:
over[f] = (members, lim, cur)
if not over:
break
free_ids = [i for i in ids if fam[i] not in caps]
freed = 0.0
for f, (members, lim, cur) in over.items():
scale = lim / cur
for i in members:
freed += w[i] * (1 - scale)
w[i] *= scale
if free_ids and freed > 0:
add = freed / len(free_ids)
for i in free_ids:
w[i] += add
else:
break
return _normalize(w)
def inverse_vol(ids: list[str], returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
sub = returns_df[ids].iloc[-lookback:]
vol = sub.std()
inv = {i: (1.0 / vol[i] if vol[i] and vol[i] > 0 else 0.0) for i in ids}
return _normalize(inv)
def cluster_rp(ids: list[str], clusters: dict[str, str],
returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
"""Equal fra i cluster naturali, poi inverse-vol dentro ogni cluster."""
groups: dict[str, list[str]] = {}
for i in ids:
groups.setdefault(clusters.get(i, i), []).append(i)
per = 1.0 / len(groups) if groups else 0.0
w: dict[str, float] = {}
for members in groups.values():
iv = inverse_vol(members, returns_df, lookback)
for i in members:
w[i] = per * iv[i]
return _normalize(w)
def weight_vector(scheme: str, ids: list[str], returns_df: pd.DataFrame | None = None,
*, weights: dict | None = None, caps: dict | None = None,
clusters: dict | None = None, lookback: int = 90) -> dict[str, float]:
if scheme == "equal":
return equal(ids)
if scheme == "manual":
return manual(ids, weights or {})
if scheme == "cap":
return cap(ids, caps or {})
if scheme == "inverse_vol":
return inverse_vol(ids, returns_df, lookback)
if scheme == "cluster_rp":
return cluster_rp(ids, clusters or {}, returns_df, lookback)
raise ValueError(f"schema peso sconosciuto: {scheme}")
-152
View File
@@ -1,152 +0,0 @@
"""Base condivisa per strategie mean-reversion con exit TP/SL/max_bars.
Tutte le strategie fade (MR02/MR03/MR07) generano Signal con metadata
{tp, sl, max_bars} e usano lo stesso backtest fedele: ingresso a close[i]
(eseguibile dal vivo), uscita su take-profit / stop-loss intrabar (high/low)
o time-limit, una posizione per volta (non-overlap), capitale composto,
fee+leva nette. Identico all'engine di scripts/analysis/strategy_research.py.
Le sottoclassi implementano solo generate_signals().
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, BacktestResult, YearlyStats, TF_MINUTES
from src.data.downloader import load_data
from src.fractal.indicators import rolling_hurst
def hurst_skip_mask(df: pd.DataFrame, hurst_max: float | None, window: int = 100,
step: int = 6) -> np.ndarray:
"""Loss-guard Hurst: maschera bool (True = SALTA il segnale) per regime PERSISTENTE/trending,
dove la rolling-Hurst >= hurst_max. Le fade concentrano stop-loss e perdite proprio li'
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente). Filtrare hurst>=0.55
DIMEZZA il DD del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe (validato 2026-06-02).
CAUSALE: rolling_hurst usa solo i rendimenti fino a close[i]. hurst_max=None -> nessuno skip.
Calcolata dalle SOLE close -> nessun feed dati esterno necessario (a differenza di DVOL).
step=6: calcola l'Hurst ogni 6 barre (ffill) -> ~6x piu' veloce per il worker live su finestre
lunghe (440g/10560 barre), e coincide con la cache di validazione (frac_step=6). L'Hurst varia
lentamente -> differenza trascurabile vs step=1."""
n = len(df)
if hurst_max is None:
return np.zeros(n, dtype=bool)
h = rolling_hurst(df["close"].values.astype(float), window=window, step=step)
return h >= hurst_max
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
def trend_distance(df: pd.DataFrame, ema_long: int = 200) -> np.ndarray:
"""Distanza del close dalla EMA lunga, in multipli di ATR(14).
Misura quanto il prezzo e' esteso rispetto al trend di fondo. Le fade
falliscono quando si oppongono a un trend estremo (crolli/parabolic): il
filtro `trend_max` salta i segnali con distanza > soglia. Riduce DD e alza
l'accuratezza (validato OOS: scripts/analysis/risk_portfolio.py).
"""
c = df["close"].values
a = atr(df, 14)
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
with np.errstate(divide="ignore", invalid="ignore"):
return np.abs(c - el) / np.where(a == 0, np.nan, a)
class FadeStrategy(Strategy):
"""Strategy con backtest intrabar TP/SL/max_bars (exit guidati dai metadata)."""
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
**params) -> BacktestResult | None:
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
if not signals:
return None
# EXIT-16 close-confirm SL (2026-06-04): se settato, lo SL intrabar e'
# DISATTIVATO e lo stop scatta solo se il CLOSE sfonda sl ∓ buf*ATR14
# (immune ai wick: l'overshoot che buca lo stop e rientra e' esattamente
# il movimento che la fade vuole fadare). TP intrabar e max_bars invariati.
# PORT06: FULL Sharpe 6.47->7.84 DD 4.10->2.60, OOS 8.82->10.06 (exit-lab,
# 34 agenti). None = comportamento storico. Diario 2026-06-04-exit-lab.md.
sl_confirm = params.get("sl_confirm_atr")
a14 = atr(df, 14) if sl_confirm else None
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
fee = self.fee_rt * self.leverage
capital = peak = float(self.initial_capital)
max_dd = 0.0
total_bars = 0
last_exit = -1
yearly: dict[int, dict] = {}
for sig in signals:
i, d = sig.idx, sig.direction
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for step in range(1, mb + 1):
j = i + step
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if sl_confirm is None:
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
if hit_sl: # conservativo: SL prima del TP nello stesso bar
exit_p = sl; break
if hit_tp:
exit_p = tp; break
else:
# close-confirm: TP intrabar al livello; SL valutato sul CLOSE
if hit_tp:
exit_p = tp; break
buf = sl_confirm * a14[j]
if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf):
exit_p = c[j]; break
if step == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * self.leverage - fee
capital = max(capital + capital * self.position_size * ret, 10.0)
if capital > peak:
peak = capital
max_dd = max(max_dd, (peak - capital) / peak)
total_bars += (j - i)
last_exit = j
year = ts.iloc[i].year
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
yr["t"] += 1
if ret > 0:
yr["w"] += 1
yr["pnl"] += ret * self.initial_capital
all_t = sum(v["t"] for v in yearly.values())
all_w = sum(v["w"] for v in yearly.values())
if all_t == 0:
return None
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)