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:
@@ -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]}")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user