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