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:
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
@@ -0,0 +1,164 @@
|
||||
"""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})
|
||||
@@ -0,0 +1,689 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,602 @@
|
||||
"""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})")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""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
|
||||
@@ -0,0 +1,342 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,426 @@
|
||||
"""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}")
|
||||
@@ -0,0 +1,277 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,284 @@
|
||||
"""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
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,770 @@
|
||||
"""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}")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user