Files
PythagorasGoal/src/live/strategy_worker.py
T
Adriano Dal Pastro 12625b3315 feat(exec): gate TP_PHANTOM — il tocco TP va confermato dal book reale, non dal feed
Il feed testnet stampa wick che 'toccano' il TP intrabar senza che il prezzo
abbia mai scambiato al livello: il sim bookava +4% fantasma a bars_held=0 e
_real_close chiudeva A MERCATO una posizione col resting TP a zero fill
(-fee/spread a giro, 14 giri l'11-06). Ora StrategyWorker._tp_phantom sopprime
l'exit take_profit quando: tocco sim + resting LIMIT zero-fill + prezzo corrente
che non ha raggiunto il livello (il limit sul book reale e' l'oracolo del tocco).
Zero parametri (verita' d'esecuzione, non filtro di strategia); SL close-confirm
e max_bars restano attivi nello stesso tick; fill parziale/prezzo oltre il
livello/worker non eseguito/errore rete -> comportamento storico. Log TP_PHANTOM
dedup per barra + alert Telegram una tantum. 5 test nuovi (104 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:32:24 +00:00

661 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
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.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 _load_state(self):
"""Riprende stato da status.json se esiste."""
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "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._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,
"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):
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
meta = signal.metadata or {}
self.tp = float(meta.get("tp", 0.0) or 0.0)
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")
self.real_in_position = True
self.real_side = side
self.real_amount = fill.amount
self.real_entry_price = fill.fill_price or sim_price
self.real_entry_fee_usd = fill.fee_usd
self.real_entry_notional = (fill.amount * self.real_entry_price
if linear else fill.amount)
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._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 market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False)
# 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)
market_amt = fill.amount if (fill and fill.verified) else 0.0
# 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:
self._notify("REAL_DIVERGENCE", {
"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)})
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
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
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
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)
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}")