#!/usr/bin/env python3 """ webhook_receiver.py — Webhook async per TV → Bybit V5. ARCHITETTURA (fix del bug timeout): TradingView ──HTTP POST──> webhook │ ├── validate secret + campi (~1ms) ├── enqueue su SQLite (persistente) ├── ritorna HTTP 200 IMMEDIATAMENTE ← chiave! │ └── background worker thread │ ├── fetch prezzo Bybit ├── auto-bump qty ├── set leva ├── place order ├── log su orders.log UNICO ├── email notification └── mark completed su DB PERCHÉ: - TradingView timeout = 5-10s - HTTP response deve tornare in <1s - Tutto il resto (chiamate Bybit lente) avviene in background - Se webhook crasha, la coda SQLite recupera gli ordini pending al restart - PID file garantisce UNA SOLA istanza attiva (no race condition) ENDPOINTS: - POST /webhook — riceve alert TV (fast, <100ms) - GET /health — health check - GET /status — stato coda (pending, completed, failed) """ import os import sys import json import time import signal import logging import threading import queue import sqlite3 import uuid from pathlib import Path from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # ============================================================================ # PATHS & LOGGING # ============================================================================ LIVE_DEPLOY = Path(r"G:\AI TRADING ENGINE\live_deploy") LOG_DIR = LIVE_DEPLOY / "logs" LOG_DIR.mkdir(exist_ok=True) LOG_FILE = LOG_DIR / "webhook_receiver.log" # log del servizio ORDERS_LOG = LOG_DIR / "orders.log" # log UNIFICATO di tutti gli ordini DB_PATH = LOG_DIR / "webhook_queue.db" # coda persistente PID_FILE = LOG_DIR / "webhook_receiver.pid" logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.FileHandler(LOG_FILE, encoding="utf-8"), logging.StreamHandler(), ], ) log = logging.getLogger("webhook") # ============================================================================ # CONFIG # ============================================================================ # Secret: supporta multi-secret via ENV WEBHOOK_SECRETS (CSV). Backward compat con hardcoded. _EXPECTED_DEFAULT = "TV_2026_MATTIA_DEMO" _webhook_secrets_env = os.environ.get("WEBHOOK_SECRETS", "").strip() if _webhook_secrets_env: ALLOWED_SECRETS = {s.strip() for s in _webhook_secrets_env.split(",") if s.strip()} else: ALLOWED_SECRETS = {_EXPECTED_DEFAULT} # Backward compat: nome usato altrove EXPECTED_SECRET = _EXPECTED_DEFAULT # Strategy default per secret (per Pine alert che non mandano "strategy" nel payload) # ENV WEBHOOK_SECRET_STRATEGY_MAP = "secret1:strategy1,secret2:strategy2" # ENV WEBHOOK_DEFAULT_STRATEGY = fallback (default: "vptr3") DEFAULT_STRATEGY = os.environ.get("WEBHOOK_DEFAULT_STRATEGY", "vptr3").strip() or "vptr3" SECRET_STRATEGY_MAP = {} _map_env = os.environ.get("WEBHOOK_SECRET_STRATEGY_MAP", "").strip() if _map_env: for pair in _map_env.split(","): if ":" in pair: sec, strat = pair.split(":", 1) SECRET_STRATEGY_MAP[sec.strip()] = strat.strip() LISTEN_HOST = "0.0.0.0" LISTEN_PORT = 5580 MIN_NOTIONAL_USD = 5.0 NOTIONAL_SAFETY_MARGIN = 1.20 # === P007 Charter fix 2026-07-19 Mavis: Regime filter EMA 50 (Rettangolo + VPTR3) === # KILL SWITCH: True = regime check attivo, False = tutto disattivato. # Se P007 causa problemi, Mattia mette False e riavvia webhook. EMA50_REGIME_ENABLED = True EMA50_PERIOD = 50 EMA50_DEFAULT_TIMEFRAME = "240" # 4H fallback se tf non specificato EMA50_LOOKBACK_CANDLES = 100 # candele sufficienti per EMA 50 stabile # ============================================================================ # SINGLETON CHECK (no multiple instances) # ============================================================================ def check_singleton(): """Se c'è già un'istanza attiva, esci. Previene race condition.""" if PID_FILE.exists(): try: old_pid = int(PID_FILE.read_text().strip()) import psutil if psutil.pid_exists(old_pid): log.error("ALTRO webhook_receiver già attivo PID=%d, esco", old_pid) sys.exit(1) except (ImportError, ValueError): pass PID_FILE.write_text(str(os.getpid())) log.info("PID file scritto: %d", os.getpid()) # ============================================================================ # PERSISTENT QUEUE (SQLite per durabilità) # ============================================================================ def init_db(): """Inizializza DB SQLite per la coda persistente.""" conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10) conn.execute("PRAGMA journal_mode=WAL") # performance conn.execute(""" CREATE TABLE IF NOT EXISTS queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT UNIQUE NOT NULL, received_at TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', error TEXT, order_id TEXT, processed_at TEXT ) """) # FIX 2026-07-19 (Mavis handoff TAILSCALE_WATCHDOG): colonna tunnel_pending # per bloccare worker quando tunnel Tailscale e' giu' (alert accettato ma non processato) try: conn.execute("ALTER TABLE queue ADD COLUMN tunnel_pending INTEGER DEFAULT 0") except Exception: pass # gia' presente conn.execute(""" CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, order_id TEXT NOT NULL, symbol TEXT NOT NULL, side TEXT NOT NULL, qty REAL NOT NULL, price REAL, notional REAL, strategy TEXT, created_at TEXT NOT NULL ) """) conn.commit() log.info("DB inizializzato: %s", DB_PATH) return conn def enqueue_request(conn, payload: dict) -> str: """Inserisce richiesta nel DB. Ritorna request_id.""" request_id = str(uuid.uuid4()) conn.execute( "INSERT INTO queue (request_id, received_at, payload) VALUES (?, ?, ?)", (request_id, datetime.now(timezone.utc).isoformat(), json.dumps(payload)) ) conn.commit() return request_id def mark_processed(conn, request_id: str, order_id: str = None, error: str = None): """Marca richiesta come processata (completed o failed).""" status = "completed" if order_id else "failed" conn.execute( "UPDATE queue SET status=?, order_id=?, error=?, processed_at=? WHERE request_id=?", (status, order_id, error, datetime.now(timezone.utc).isoformat(), request_id) ) conn.commit() def get_pending(conn): """Ritorna richieste pending (per recovery al startup).""" rows = conn.execute( "SELECT request_id, payload FROM queue WHERE status='pending' ORDER BY id LIMIT 100" ).fetchall() return [(r[0], json.loads(r[1])) for r in rows] def get_pending_unblocked(conn): """FIX 2026-07-19 (Mavis handoff TAILSCALE_WATCHDOG): ritorna solo richieste pending NON bloccate da tunnel_pending=1. Il worker processa queste; le richieste con tunnel_pending=1 restano in coda finche' il tunnel non torna su (segnalato da set_tunnel_state).""" rows = conn.execute( "SELECT request_id, payload FROM queue WHERE status='pending' AND tunnel_pending=0 ORDER BY id LIMIT 100" ).fetchall() return [(r[0], json.loads(r[1])) for r in rows] def count_pending_tunnel_blocked(conn) -> int: """FIX 2026-07-19: conta richieste in attesa che il tunnel torni su.""" row = conn.execute( "SELECT COUNT(*) FROM queue WHERE status='pending' AND tunnel_pending=1" ).fetchone() return row[0] if row else 0 def unblock_all_tunnel_pending(conn) -> int: """FIX 2026-07-19: quando il tunnel torna su, sblocca TUTTE le richieste in attesa (impostando tunnel_pending=0). Ritorna il numero sbloccato. Le richieste vengono raccolte dal worker al prossimo get_pending_unblocked().""" cur = conn.execute( "UPDATE queue SET tunnel_pending=0 WHERE status='pending' AND tunnel_pending=1" ) conn.commit() return cur.rowcount def set_tunnel_block_on_request(conn, request_id: str, blocked: bool = True): """FIX 2026-07-19: marca una richiesta come bloccata (tunnel_pending=1) o sbloccata (tunnel_pending=0). Usato in do_POST quando tunnel e' giu'.""" conn.execute( "UPDATE queue SET tunnel_pending=? WHERE request_id=?", (1 if blocked else 0, request_id) ) conn.commit() def log_order_unified(conn, request_id, order_id, symbol, side, qty, price, notional, strategy): """Scrive su orders.log UNIFICATO + DB orders table.""" ts = datetime.now(timezone.utc).isoformat() line = f"[{ts}] orderId={order_id} {symbol} {side} qty={qty} price={price} notional={round(notional, 4)} strategy={strategy}" log.info("ORDER: %s", line) # File unificato (append, con lock logico) with open(ORDERS_LOG, "a", encoding="utf-8") as f: f.write(line + "\n") # DB conn.execute( "INSERT INTO orders (request_id, order_id, symbol, side, qty, price, notional, strategy, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (request_id, order_id, symbol, side, qty, price, notional, strategy, ts) ) conn.commit() # ============================================================================ # BYBIT CLIENT (lazy import) # ============================================================================ _bybit = None def get_bybit(): global _bybit if _bybit is None: from bybit_demo_client import BybitDemoClient from dotenv import load_dotenv load_dotenv(LIVE_DEPLOY / "API_KEY_BYBIT.env") _bybit = BybitDemoClient() # FIX 2026-07-18 (Verifier): no parametri, legge da ENV_FILE auto return _bybit # ============================================================================ # WORKER (background thread) # ============================================================================ def worker_loop(conn, in_queue: queue.Queue): """Worker che processa la coda in background. UN solo worker per evitare conflitti.""" log.info("WORKER started (background)") while True: try: item = in_queue.get() if item is None: # shutdown signal break request_id, payload = item try: process_order(conn, request_id, payload) except Exception as e: log.exception("Worker error on %s: %s", request_id, e) try: mark_processed(conn, request_id, error=str(e)) except Exception: pass finally: in_queue.task_done() except Exception as e: log.exception("Worker loop error: %s", e) time.sleep(1) def process_order(conn, request_id: str, payload: dict): """Processa un singolo ordine. Può richiedere diversi secondi.""" log.info("Processing %s: %s %s qty=%s", request_id, payload.get("symbol"), payload.get("side"), payload.get("qty")) # Parse fields symbol_raw = str(payload.get("symbol", "")).strip() symbol = symbol_raw.replace(".P", "").replace(".PZ", "").upper() if not symbol.endswith("USDT") and "USDT" not in symbol: symbol = symbol + "USDT" side = str(payload.get("side", "")).lower().strip() if side not in ("buy", "sell"): raise ValueError(f"Invalid side: {side!r}") try: qty = float(payload.get("qty", 0)) except (TypeError, ValueError): raise ValueError(f"Invalid qty: {payload.get('qty')!r}") leverage = int(payload.get("leverage", 3) or 3) if leverage < 1 or leverage > 100: leverage = 3 def safe_float(v): try: return float(v) if v is not None else None except (TypeError, ValueError): return None price = safe_float(payload.get("price")) sl_price = safe_float(payload.get("sl_price")) tp_price = safe_float(payload.get("tp_price")) # Strategy: prima leggi dal payload; se mancante o "unknown", default per secret ricevuto. strategy_raw = payload.get("strategy") if strategy_raw is None or str(strategy_raw).strip() == "" or str(strategy_raw).strip().lower() == "unknown": sec = str(payload.get("secret", "")).strip() strategy = SECRET_STRATEGY_MAP.get(sec, DEFAULT_STRATEGY) log.warning("STRATEGY missing/unknown in payload for %s %s → using default=%s (secret=***%s)", symbol, side, strategy, sec[-4:] if sec else "none") else: strategy = str(strategy_raw).strip() tf = str(payload.get("tf", "unknown")) comment = str(payload.get("comment", "")) bybit = get_bybit() # 0. CLOSE INTENT (Pine "Max exit bar" / strategy.close_all / strategy.close): # Trigger quando: # (a) qty <= 0 (close esplicito Pine), OPPURE # (b) comment contiene "Max exit" / "exit bars" / "Opposite signal" (Pine # strategy.close_all/comment="Max exit bars" manda qty>0 + comment marker) # Se triggered: NON aprire un nuovo ordine. Invece: # - Recupera posizione aperta su Bybit per symbol # - Se c'è: market order opposta con reduceOnly=True + size = pos_size corrente # - Se NON c'è: log + return 200 OK idempotente (no errore) # Questo NON rompe il flusso Pine→Bybit per aperture normali (qty>0, no marker). is_close_intent = qty <= 0 if not is_close_intent and comment: comment_lower = comment.lower() if any(kw in comment_lower for kw in ("max exit", "exit bars", "opposite signal")): is_close_intent = True log.info("CLOSE INTENT detected via comment marker: %r (qty=%s side=%s strategy=%s)", comment, qty, side, strategy) if is_close_intent: if qty > 0: log.info("CLOSE INTENT (comment-based): %s side=%s qty=%s comment=%r → recupero posizione Bybit", symbol, side, qty, comment) log.info("CLOSE INTENT: %s side=%s qty=%s → recupero posizione Bybit", symbol, side, qty) try: positions = bybit.fetch_positions() except Exception as e: raise RuntimeError(f"CLOSE INTENT: cannot fetch positions for {symbol}: {e}") pos = next((p for p in positions if p.get("symbol") == symbol and float(p.get("size", 0) or 0) > 0), None) if pos is None: log.info("CLOSE INTENT: no open position for %s → skip idempotent (HTTP 200)", symbol) log_order_unified(conn, request_id, "no_position", symbol, side, 0, 0, 0, f"{strategy}_close_no_pos") mark_processed(conn, request_id, error="no_position") return pos_size = float(pos["size"]) pos_side = str(pos.get("side", "Buy")) close_side = "Sell" if pos_side == "Buy" else "Buy" try: current_price = bybit.get_last_price(symbol) except Exception as e: raise RuntimeError(f"CLOSE INTENT: cannot fetch price for {symbol}: {e}") log.info("CLOSE INTENT: %s current pos side=%s size=%s → placing %s qty=%s reduceOnly=True @ market", symbol, pos_side, pos_size, close_side, pos_size) try: order = bybit._request("POST", "/v5/order/create", { "category": "linear", "symbol": symbol, "side": close_side, "orderType": "Market", "qty": str(pos_size), "timeInForce": "GTC", "reduceOnly": True }, signed=True) except Exception as e: raise RuntimeError(f"CLOSE INTENT: order failed: {e}") order_id = (order or {}).get("orderId") or (order or {}).get("result", {}).get("orderId") if not order_id: raise RuntimeError(f"CLOSE INTENT: order returned but no orderId: {order}") notional_close = pos_size * current_price log.info("CLOSE OK %s: orderId=%s side=%s qty=%s reduceOnly=True (was %s)", request_id, order_id, close_side, pos_size, pos_side) log_order_unified(conn, request_id, order_id, symbol, close_side.lower(), pos_size, current_price, notional_close, f"{strategy}_close") mark_processed(conn, request_id, order_id=order_id) return # 0b. ANTI-DOPPIA POSIZIONE (Mattia 21/07 15:42 "in live mi brucio il capitale") # Pine Script manda alert a raffica (132 ETH + 132 WIF in pochi minuti → mega-posizioni 86K+55K USDT). # Questo check impedisce di aprire un NUOVO ordine sullo stesso symbol se: # - c'è già posizione aperta sullo STESSO SIDE (anti-pyramiding) # - c'è già posizione aperta su side OPPOSTO (Pine sta invertendo, NO nuova posizione) # SKIP con HTTP 200 idempotente. NON tocca posizioni esistenti (regola FERREA MAI chiudere manualmente). # Si applica SOLO ai nuovi ordini (qty>0, no close intent). try: positions = bybit.fetch_positions() existing = next((p for p in positions if p.get("symbol") == symbol and float(p.get("size", 0) or 0) > 0), None) if existing is not None: existing_side = existing.get("side", "").lower() existing_size = float(existing.get("size", 0) or 0) existing_entry = float(existing.get("avgPrice", 0) or existing.get("entryPrice", 0) or 0) log.warning("ANTI-DOPPIA SKIP: %s side=%s qty=%s ma c'è GIA' posizione %s size=%s entry=%s (no pyramiding, no chiusura manuale)", symbol, side, qty, existing_side, existing_size, existing_entry) log_order_unified(conn, request_id, "anti_dup_skip", symbol, side, qty, price or 0, 0, f"{strategy}_anti_dup_skip") mark_processed(conn, request_id, error=f"anti_dup_skip_existing_{existing_side}") return 200, {"status": "skipped", "reason": f"ANTI-DOPPIA: posizione gia' aperta su {symbol} (side={existing_side} size={existing_size}). Pine ha mandato alert a raffica, no pyramiding."} except Exception as e: # Se fetch_positions fallisce, NON bloccare l'ordine (FAIL-OPEN). # Abbiamo gia' il check P007/P007b/ZONA-MID come safety net. log.error("ANTI-DOPPIA check exception for %s %s: %s (FAIL-OPEN, ordine passa)", symbol, side, e) # 1. Fetch price if not provided if price is None: try: price = bybit.get_last_price(symbol) log.info("Price fetched from Bybit: %s = %s", symbol, price) except Exception as e: raise RuntimeError(f"Cannot fetch price for {symbol}: {e}") # 2. Auto-bump qty if notional too low notional = qty * price if notional < MIN_NOTIONAL_USD: new_qty = (MIN_NOTIONAL_USD * NOTIONAL_SAFETY_MARGIN) / price new_qty = bybit.round_qty(symbol, new_qty) # Mavis 2026-07-20: arrotonda al qtyStep Bybit (no Qty invalid) log.warning("Auto-bump qty: %s → %s (notional %.2f$ < %.2f$)", qty, new_qty, notional, MIN_NOTIONAL_USD) qty = new_qty notional = qty * price # 3. Set leverage try: bybit.set_leverage(symbol, leverage) except Exception as e: log.warning("set_leverage failed (potrebbe essere già impostata): %s", e) # 4. Place order + set trading stop (SL/TP) come operazioni separate log.info("ORDER → symbol=%s side=%s qty=%.4f price=%.4f leverage=%d SL=%s TP=%s strategy=%s", symbol, side, qty, price, leverage, sl_price, tp_price, strategy) # === FIX 2026-07-19 Coder — SIZING ENFORCEMENT 1500 USDT (Charter Mattia 500×3x) === # Regola fissa: ogni ordine Bybit = 500 USDT margin × 3x = 1500 USDT nozionale. # Se Pine invia qty che produce nozionale fuori tolleranza ±5%, ri-calibriamo # server-side a 1500 USDT nozionale. NON blocchiamo mai: solo log WARN + override. TARGET_NOTIONAL = 1500.0 TOLERANCE = 0.05 current_notional = qty * price if abs(current_notional - TARGET_NOTIONAL) / TARGET_NOTIONAL > TOLERANCE: new_qty = TARGET_NOTIONAL / price new_qty = bybit.round_qty(symbol, new_qty) # Mavis 2026-07-20: arrotonda al qtyStep Bybit (no Qty invalid) log.warning("SIZING ENFORCEMENT: %s qty=%.4f -> %.4f (nozionale %.2f -> %.2f USDT, target 1500)", symbol, qty, new_qty, current_notional, TARGET_NOTIONAL) qty = new_qty notional = qty * price # === P007 Charter: regime filter EMA 50 (Mavis 19/07 13:20) === # Regime check Pine-faithful: long richiede close[-2] > EMA50[-2], short viceversa. # Forward-looking: si applica solo ai NUOVI ordini (no close retroattivo). # Skip-regime se fallisce, skip-check (no blocco) se dati insufficienti. # FIX 2026-07-21 Mavis: P007 EMA50 SOLO per VPTR3. MAI per Rettangolo. # Motivo: il pattern Rettangolo ha bottom/top definiti dal pattern stesso. # Uno SHORT al top del pattern ha SEMPRE close > EMA50 (siamo in zona resistenza). # Applicare P007 al Rettangolo bloccherebbe TUTTI gli short legittimi. # Il Rettangolo usa solo P007b (CHOP filter) + ZONA-MID 1% (sl_ref pattern). strategy_name = payload.get("strategy", "unknown").lower() if strategy_name in ("vptr3", "vptr_v3"): # SOLO VPTR3 — Rettangolo escluso bybit_for_regime = bybit # riusa la connessione Bybit gia' inizializzata regime_pass, regime_reason = check_regime_ema50(bybit_for_regime, symbol, side, strategy_name) log.info("P007 regime check %s %s: %s", symbol, side, regime_reason) if not regime_pass: log.warning("P007 SKIP-REGIME ATTIVATO per %s %s: %s — ordine NON inoltrato a Bybit", symbol, side, regime_reason) # Forward-looking: NON chiudiamo posizioni gia' aperte, solo skip del NUOVO ordine return 200, {"status": "skipped", "reason": regime_reason} # === P007b Charter: CHOP filter via market_regime.classify_regime (Mavis 21/07 10:10 Mattia) # Mattia 21/07 10:08: "E CHOP LA REGHOLA CHE AVEVAMO INSERITO PER VPTR3" # Il file charter_core/market_regime.py (creato 20/07) esiste gia' con ADX/ATR/BB classifier # MA NON era integrato nel webhook. Lo collego ADESSO. # Se is_chop=True → SKIP ordine (FAIL-CLOSED: meglio perdere un'occasione che aprire in CHOP). # Se errore tecnico (fetch_ohlcv, dati insufficienti) → FAIL-OPEN + log WARN (non bloccare). # Timeframe: VPTR3 = 4H (240), Rettangolo = 30m (30). # Forward-looking: si applica SOLO ai nuovi ordini. Posizioni gia' aperte NON vengono toccate # (regola FERREA Mattia 19/07 "MAI chiudere manualmente"). if strategy_name in ("vptr3", "vptr_v3", "rettangolo"): try: import sys as _sys _sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from charter_core.market_regime import classify_regime tf_chop = "240" if strategy_name.startswith("vptr") else "30" regime_info = classify_regime(bybit, symbol, tf_chop) adx_str = f"{regime_info['adx']:.1f}" if regime_info.get('adx') is not None else "?" log.info("P007b CHOP check %s %s tf=%s: regime=%s chop=%s adx=%s (%s)", symbol, side, tf_chop, regime_info['regime'], regime_info['is_chop'], adx_str, regime_info['reason']) if regime_info['is_chop']: log.warning("P007b SKIP-CHOP ATTIVATO per %s %s: regime=%s chop=%s adx=%s reason=%s — ordine NON inoltrato a Bybit", symbol, side, regime_info['regime'], regime_info['is_chop'], adx_str, regime_info['reason']) return 200, {"status": "skipped", "reason": f"P007b CHOP: regime={regime_info['regime']} adx={adx_str} ({regime_info['reason']})"} except Exception as e: log.error("P007b CHOP check exception for %s %s: %s (FAIL-OPEN, ordine passa)", symbol, side, e) # === P008 Charter — MAX 5 posizioni aperte — DISATTIVATO 2026-07-20 19:58 Mattia === # Mattia: "togli il limite dei 5 trade lascia libero. Piu trade abbiamo da controllare # piu casistica abbiamo da capire e verificare". Il check è stato commentato per # massimizzare la casistica entro 1° Agosto 2026. La costante MAX_OPEN_TRADES_PORTFOLIO # resta in charter_core/charter_config.py (non toccata, per compatibilità con altri import). # Per riattivare: rimuovere questo commento e ripristinare il blocco try/except sottostante. pass # P008 DISATTIVATO — Lasciamo libero, niente skip per max posizioni aperte # === FIX 2026-07-20 Mavis v4.6 — ZONA MID SKIP SOLO RETTANGOLO (Mattia 22:48 STOP "VPTR3 NON CENTRA UNA MINCHIA CON L'1%") # REGOLA Mattia: ZONA-MID 1% si applica SOLO alla strategia RETTANGOLO (dove sl_ref = bottom/top del pattern). # Per VPTR3 (alert TV) NON si applica MAI: l'alert Pine su TV è già al bottom/top del pattern, # e il fallback range daily (d1_low/d1_high) era una CAZZATA perché confrontava con la candela # daily di ieri, che non c'entra col pattern attuale (skip BTC 22:00:32 ne era la prova). # Se il payload contiene `sl_ref` (= LL/HH del pattern da rettangolo_runner), applica ZONA-MID 1%. # Altrimenti (VPTR3, payload senza sl_ref) → NON applicare ZONA-MID, lascia passare. # - LONG: entry DEVE essere <= bottom_pattern * 1.01 (1% UP dal bottom). # - SHORT: entry DEVE essere >= top_pattern * 0.99 (1% DOWN dal top). # Se supera 1% (e sl_ref presente) → SKIP ordine, NON si entra in zona mid. # FAIL-CLOSED: se check fallisce per errore Bybit, SKIP ordine (regola Mattia: # in zona mid NON si entra MAI, meglio perdere un'occasione che aprire male). # Test retrospettivo ZEC: bottom 525.25, entry 548.83 → 1% UP = 530.50, entry > 530.50 → SKIP ✅ ZONA_ENTRY_PCT = 0.01 # 1% dal bottom/top del PATTERN. SOLO Rettangolo (sl_ref). Mai VPTR3. _sl_ref = None try: _sl_ref_raw = payload.get("sl_ref") if _sl_ref_raw is not None: _sl_ref = float(_sl_ref_raw) except (TypeError, ValueError): _sl_ref = None _direction = 1 if side.lower() in ("buy", "long") else -1 try: if _sl_ref is not None and _sl_ref > 0: # Path A: usa sl_ref dal payload (rettangolo_runner passa LL/HH del pattern) if _direction == 1: # LONG: entry <= bottom * 1.015 _zona_max = _sl_ref * (1 + ZONA_ENTRY_PCT) if price > _zona_max: log.error("ZONA-MID SKIP: %s LONG entry=%.6f > bottom_pattern*1.015 = %.6f " "(entry OLTRE 1.5%% dal bottom %.6f, NON si entra in zona mid). " "sl_ref=%.6f ZONA_ENTRY_PCT=%.3f", symbol, price, _zona_max, _sl_ref, _sl_ref, ZONA_ENTRY_PCT) return 200, {"status": "skipped", "reason": f"ZONA-MID LONG: entry {price:.6f} > bottom_pattern*1.015 ({_zona_max:.6f}) — OLTRE 1.5% dal bottom {_sl_ref:.6f}, NO entry in zona mid"} log.info("ZONA-MID LONG OK: %s entry=%.6f <= %.6f (1.5%% UP dal bottom_pattern %.6f)", symbol, price, _zona_max, _sl_ref) else: # SHORT: entry >= top * 0.985 _zona_min = _sl_ref * (1 - ZONA_ENTRY_PCT) if price < _zona_min: log.error("ZONA-MID SKIP: %s SHORT entry=%.6f < top_pattern*0.985 = %.6f " "(entry OLTRE 1.5%% dal top %.6f, NON si entra in zona mid). " "sl_ref=%.6f ZONA_ENTRY_PCT=%.3f", symbol, price, _zona_min, _sl_ref, _sl_ref, ZONA_ENTRY_PCT) return 200, {"status": "skipped", "reason": f"ZONA-MID SHORT: entry {price:.6f} < top_pattern*0.985 ({_zona_min:.6f}) — OLTRE 1.5% dal top {_sl_ref:.6f}, NO entry in zona mid"} log.info("ZONA-MID SHORT OK: %s entry=%.6f >= %.6f (1.5%% DOWN dal top_pattern %.6f)", symbol, price, _zona_min, _sl_ref) else: # Mattia 22:48 STOP: "VPTR3 NON CENTRA UNA MINCHIA CON L'1%". # ZONA-MID 1% si applica SOLO al RETTANGOLO (perché ha sl_ref = bottom/top del pattern). # Per VPTR3 (alert TV diretto) NON si applica MAI: l'alert Pine su TV è già al bottom/top del pattern, # e il fallback daily (d1_low/d1_high) era una CAZZATA perché confrontava con la candela daily # di ieri, che non c'entra col pattern attuale (skip BTC 22:00:32 ne era la prova). # → Lascia passare (P007 regime EMA50 è il filtro corretto per VPTR3). log.info("ZONA-MID: sl_ref assente per %s (strategy=%s). ZONA-MID si applica SOLO al Rettangolo. VPTR3 (alert TV) non rientra. Lascio passare al filtro successivo.", symbol, strategy_name) except Exception as e: log.error("ZONA-MID check FAILED for %s %s: %s — ordine SKIPPATO (fail-closed Mattia: meglio perdere occasione che aprire male)", symbol, side, e) return 200, {"status": "skipped", "reason": f"ZONA-MID check failed: {e} (fail-closed, no entry in zona dubbia)"} order = bybit.create_market_order(symbol=symbol, side=side, qty=qty) # === FIX 2026-07-18 Coder — VPTR3 SL/TP auto-fill (Charter compliance) === # Pine Script TV NON calcola SL/TP per VPTR3: se sl_price/tp_price sono None # nel payload, li calcoliamo server-side da charter_core SETUPS (single source # of truth) per rispettare P005 (TP 50/50), P006 (SL ATR 2x clamp -3%), # P013 (SL nativo Bybit V5 via set_trading_stop). # - Simboli Charter (AEROUSDT/ZECUSDT/DASHUSDT): usa SETUPS_BY_SYMBOL + Charter P006 # - Simboli NON Charter (SOLUSDT/TAOUSDT/ecc.): fallback -2% SL / +5% TP # Side-aware: LONG direction=+1 (SL sotto, TP sopra); SHORT direction=-1. # Vedi _HUB/fix/fix_Coder_20260718_VPTR3_SL_TP.md per audit completo. # # === FIX 2026-07-19 Coder — Charter P006 SL clamp -3% HARD (UNIVERSAL) === # BUG CRIT (Mavis handoff 20260719_VPTR3_SL_ClampMinus3): # - ZEC chiusa con SL a -6.24% (sfora Charter P006 -3% di oltre 2x). # - Causa 1 (auto-fill): formula precedente era # `sl_pct = max(-setup["sl_atr_mult"] * 0.01, setup["sl_clamp_min"])` # che per AERO/ZEC/DASH produceva -0.02 (2% loss) invece di -0.03 # (3% loss) come Charter P006 hard richiede. La formula usava SOLO # sl_clamp_min, ignorando sl_clamp_max. # - Causa 2 (Pine sl_price esplicito): Pine potrebbe mandare sl_price # con loss > 3% (es. ATR ampio calcolato lato Pine) SENZA alcun clamp # server-side. Vecchio codice rispettava ciecamente il Pine sl_price. # FIX (Charter P006 hard, NON bypassabile, NON opt-in): # 1. AUTO-FILL Charter: usa `setup["sl_clamp_max"]` = -0.03 FISSO # (no ATR-dinamico: webhook non ha ATR live; Charter hard vuole -3% # loss SEMPRE per AERO/ZEC/DASH). # 2. SAFETY BELT universale (sotto): se sl_price loss > 3% (anche Pine # esplicito) → clamp a -3% Charter con log ERRORE visibile. # Charter P006: "SL ATR 2x clampato a -3% (sl_clamp_min == sl_clamp_max # == -0.03). Hard: mai meno del -3%, mai piu' del -3%." # Vedi _HUB/fix/fix_Coder_20260719_VPTR3_SL_ClampMinus3.md per audit. CHARTER_P006_SL_CAP = -0.03 # Charter P006 hard: max loss -3% (NON bypassabile) if strategy == "vptr3" and (sl_price is None or tp_price is None): try: # Path setup per import charter_core (workspace root = parent di live_deploy) _WS_ROOT = str(LIVE_DEPLOY.parent) if _WS_ROOT not in sys.path: sys.path.insert(0, _WS_ROOT) from charter_core import SETUPS_BY_SYMBOL setup = SETUPS_BY_SYMBOL.get(symbol) if setup is not None: # Charter P006: per webhook (no ATR live), usa sl_clamp_max = -0.03 # come worst case FISSO. sl_atr_mult*atr/entry oscillerebbe # attorno, ma Charter hard vuole -3% loss SEMPRE per AERO/ZEC/DASH. # (Vecchia formula: max(-sl_atr_mult*0.01, sl_clamp_min) produceva # -0.02 per via del 0.01 hardcoded — IGNORAVA sl_clamp_max.) sl_pct = setup["sl_clamp_max"] # -0.03 Charter P006 hard tp_pct = setup["tp2_pct"] # +0.05 Charter P005 TP2 conservativo sl_source = (f"Charter P006 {setup['name']} " f"(clamp_min={setup['sl_clamp_min']}, clamp_max={setup['sl_clamp_max']})") else: # Fallback per simboli non Charter (SOLUSDT, TAOUSDT, ecc.) # -2% SL è entro Charter P006 (-3% max loss) → OK sl_pct = -0.02 tp_pct = 0.05 sl_source = "fallback -2%SL/+5%TP (symbol non in SETUPS Charter)" log.warning("AUTO-FILL SL/TP: symbol %s NON in SETUPS Charter → fallback -2%% SL / +5%% TP", symbol) # side-aware: direction +1 LONG (SL sotto entry, TP sopra); -1 SHORT (inverso) direction = 1 if side.lower() in ("buy", "long") else -1 entry_price = price # mark/last Bybit (o Pine) usato come riferimento SL/TP if sl_price is None: sl_price = entry_price * (1 + direction * sl_pct) if tp_price is None: tp_price = entry_price * (1 + direction * tp_pct) log.info("AUTO-FILL SL/TP per %s %s @ entry=%.6f: SL=%.6f TP=%.6f source=%s (sl_pct=%.4f tp_pct=%.4f)", symbol, side, entry_price, sl_price, tp_price, sl_source, sl_pct, tp_pct) except Exception as e: log.error("AUTO-FILL SL/TP FAILED for %s %s: %s — proceeding senza SL/TP (sltp_engine coprira' in 5min)", symbol, side, e) # === FIX 2026-07-19 Coder — Charter P006 SAFETY BELT (UNIVERSAL) === # Charter P006 hard: "MAI più del -3% loss". Applicato ANCHE se Pine ha # mandato sl_price esplicito > -3% loss. NON bypassabile, NON opt-in. # Esegue DOPO auto-fill, PRIMA di set_trading_stop. # Tolleranza 1e-6 su floating point per evitare falsi positivi quando # AUTO-FILL produce esattamente -0.03 (sl_pct = sl_clamp_max). if sl_price is not None and price is not None: _direction = 1 if side.lower() in ("buy", "long") else -1 # sl_loss_pct: negativo se loss, positivo se profit # LONG: sl < entry → (sl-entry)/entry < 0 (loss) → *_direction +1 → negativo ✓ # SHORT: sl > entry → (sl-entry)/entry > 0 (loss se direction=-1) → *_direction -1 → negativo ✓ _sl_loss_pct = _direction * (sl_price - price) / price if _sl_loss_pct < CHARTER_P006_SL_CAP - 1e-6: # loss > 3% Charter (tolleranza FP) _old_sl = sl_price sl_price = price * (1 + _direction * CHARTER_P006_SL_CAP) log.error("CHARTER P006 OVERRIDE: %s sl_loss_pct=%.4f (loss %.2f%%) > -3%% Charter hard cap, " "clampato SL da %s a %s (entry=%s side=%s)", symbol, _sl_loss_pct, _sl_loss_pct * 100, _old_sl, sl_price, price, side) # Dopo l'apertura, set SL/TP se specificati (riduce impatto slippage in apertura) if sl_price is not None or tp_price is not None: try: bybit.set_trading_stop(symbol, sl_price=sl_price, tp_price=tp_price) log.info("TRADING STOP OK %s: SL=%s TP=%s", symbol, sl_price, tp_price) except Exception as e: log.warning("set_trading_stop failed (ordine aperto, ma SL/TP da settare): %s", e) order_id = (order or {}).get("orderId") or (order or {}).get("result", {}).get("orderId") if not order_id: raise RuntimeError(f"Order returned but no orderId: {order}") log.info("ORDER OK %s: orderId=%s", request_id, order_id) # 5. Log unified + DB log_order_unified(conn, request_id, order_id, symbol, side, qty, price, notional, strategy) mark_processed(conn, request_id, order_id=order_id) # 6. Email (non-blocking failure) try: from email_notifier import send_trade_email send_trade_email( order={"orderId": order_id, "symbol": symbol, "side": side, "qty": qty, "price": price, "avgPrice": price, "notional_usd": round(notional, 4)}, signal={"strategy": strategy, "reason": comment or "TV webhook", "stop": sl_price, "tp": tp_price, "leverage": leverage, "tf": tf}, ) except Exception as e: log.warning("Email notification failed (non blocca): %s", e) # ============================================================================ # HTTP HANDLER (FAST PATH - deve rispondere in <100ms) # ============================================================================ class WebhookHandler(BaseHTTPRequestHandler): db = None # set in main() in_queue = None # set in main() def log_message(self, format, *args): pass # silenzia logger di default (usiamo il nostro) def _send_json(self, code, payload): body = json.dumps(payload).encode("utf-8") try: self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) except Exception as e: log.warning("Send failed: %s", e) def do_GET(self): path = self.path.split("?")[0] if path == "/health": return self._send_json(200, { "ok": True, "service": "bybit-webhook", "ts": datetime.now(timezone.utc).isoformat(), }) if path == "/status": try: pending = self.db.execute( "SELECT COUNT(*) FROM queue WHERE status='pending'" ).fetchone()[0] completed = self.db.execute( "SELECT COUNT(*) FROM queue WHERE status='completed'" ).fetchone()[0] failed = self.db.execute( "SELECT COUNT(*) FROM queue WHERE status='failed'" ).fetchone()[0] # FIX 2026-07-19: mostra anche tunnel_blocked tunnel_blocked = count_pending_tunnel_blocked(self.db) return self._send_json(200, { "ok": True, "queue_pending": pending, "queue_completed": completed, "queue_failed": failed, "queue_tunnel_blocked": tunnel_blocked, "queue_size_in_memory": self.in_queue.qsize(), }) except Exception as e: return self._send_json(500, {"error": str(e)}) return self._send_json(404, {"error": "not found"}) def do_POST(self): path = self.path.split("?")[0] # FIX 2026-07-19 (Mavis handoff TAILSCALE_WATCHDOG): endpoint per supervisor # per marcare tunnel UP/DOWN. Quando va UP, sblocca tutte le richieste in coda. if path == "/tunnel-state": try: length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length).decode("utf-8", errors="replace") if length else "{}" data = json.loads(raw) state = str(data.get("state", "")).strip().lower() if state not in ("up", "down"): return self._send_json(400, {"error": "state must be 'up' or 'down'"}) # Scrivi marker file per do_POST veloce (no I/O DB) try: (LOG_DIR / "tunnel_state.txt").write_text(state, encoding="utf-8") except Exception: pass if state == "up": unblocked = unblock_all_tunnel_pending(self.db) log.info("TUNNEL UP: sbloccate %d richieste pending (tunnel_pending=0)", unblocked) return self._send_json(200, {"ok": True, "state": "up", "unblocked": unblocked}) else: n = count_pending_tunnel_blocked(self.db) log.warning("TUNNEL DOWN: %d richieste gia' bloccate, nuove richieste saranno accettate ma non processate", n) return self._send_json(200, {"ok": True, "state": "down", "already_blocked": n}) except Exception as e: return self._send_json(500, {"error": str(e)}) if path != "/webhook": return self._send_json(404, {"error": "not found"}) # === FAST PATH (target: <100ms) === # 1. Read body try: length = int(self.headers.get("Content-Length", "0") or 0) raw = self.rfile.read(length).decode("utf-8", errors="replace") if length else "" except Exception as e: return self._send_json(400, {"error": "read failed", "detail": str(e)}) # 2. Parse JSON try: data = json.loads(raw) except Exception as e: return self._send_json(400, {"error": "invalid JSON", "detail": str(e)}) # 3. Validate secret (veloce, no I/O) received_secret = str(data.get("secret", "")).strip() if received_secret not in ALLOWED_SECRETS: log.warning("REJECTED: wrong secret (received=%r, allowed=%d)", received_secret, len(ALLOWED_SECRETS)) return self._send_json(401, {"error": "WRONG_SECRET"}) # 4. Validate essential fields if not data.get("symbol"): return self._send_json(400, {"error": "missing symbol"}) if not data.get("side"): return self._send_json(400, {"error": "missing side"}) # qty: accetta 0/negativo come "close intent" (Pine strategy.close_all / Max exit bar) # Se mancante del tutto (None, ""), errore. if data.get("qty") is None or data.get("qty") == "": return self._send_json(400, {"error": "missing qty"}) # 5. Persist + queue (I/O veloce su SQLite locale) try: request_id = enqueue_request(self.db, data) self.in_queue.put((request_id, data)) except Exception as e: log.exception("Queue/persist failed: %s", e) return self._send_json(500, {"error": "queue failed", "detail": str(e)}) # FIX 2026-07-19 (Mavis handoff TAILSCALE_WATCHDOG): se tunnel Tailscale e' DOWN, # marca la richiesta come tunnel_pending=1 cosi' il worker NON la processa # finche' il supervisor non chiama /tunnel-state up. Intanto 200 OK a TradingView # per evitare ritrasmissioni. Lettura marker file (veloce, no I/O DB). tunnel_blocked = False try: marker = (LOG_DIR / "tunnel_state.txt").read_text(encoding="utf-8").strip().lower() if marker == "down": set_tunnel_block_on_request(self.db, request_id, blocked=True) tunnel_blocked = True log.warning("TUNNEL DOWN: richiesta %s accettata ma bloccata (worker non la processa finche' tunnel UP)", request_id) except FileNotFoundError: pass # marker assente = tunnel ok except Exception as e: log.warning("Marker tunnel_state.txt read failed: %s", e) # 6. RETURN 200 IMMEDIATELY log.info("ACCEPTED %s: %s %s qty=%s (queued%s)", request_id, data.get("symbol"), data.get("side"), data.get("qty"), ", tunnel_blocked" if tunnel_blocked else "") return self._send_json(200, { "ok": True, "request_id": request_id, "queued_at": datetime.now(timezone.utc).isoformat(), "message": "Order queued, will be processed in background", "tunnel_blocked": tunnel_blocked, }) # ============================================================================ # MAIN # ============================================================================ def main(): # Singleton check check_singleton() # Init DB db = init_db() # Recovery: re-queue pending items from previous run (crash safety) # FIX 2026-07-19 (Mavis handoff TAILSCALE_WATCHDOG): usa get_pending_unblocked # per escludere richieste con tunnel_pending=1 (il worker le ignorera' finche' # il supervisor non segnala tunnel UP). pending = get_pending_unblocked(db) in_queue = queue.Queue() if pending: log.warning("RECOVERY: %d pending items from previous run, re-queuing", len(pending)) for item in pending: in_queue.put(item) else: log.info("No pending items to recover") # Spawn worker thread (background) worker = threading.Thread(target=worker_loop, args=(db, in_queue), daemon=True) worker.start() # Pass shared state to handler WebhookHandler.db = db WebhookHandler.in_queue = in_queue # Start HTTP server (ThreadingHTTPServer for concurrent requests) server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler) log.info("=" * 60) log.info("WEBHOOK READY (async pattern)") log.info("Listening on %s:%d", LISTEN_HOST, LISTEN_PORT) log.info("Fast path: <100ms (validation + queue + 200)") log.info("Background worker: process order in separate thread") log.info("=" * 60) # Shutdown handler def shutdown(signum, frame): log.info("Shutdown signal received") server.shutdown() signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) try: server.serve_forever() finally: # Cleanup PID file try: PID_FILE.unlink() except Exception: pass log.info("Server stopped") # ============================================================================ # P007 CHARTER — REGIME FILTER EMA 50 (helper functions, Mavis 19/07 13:20) # ============================================================================ def compute_ema50(closes, period=50): """Calcola EMA 50 su una lista di close. Ritorna float o None. Usa la formula standard EMA = close * k + prev_ema * (1-k), k=2/(period+1). Seed: SMA delle prime `period` candele. Gestione input insufficienti: None.""" if not closes or len(closes) < period: return None k = 2.0 / (period + 1) ema = sum(closes[:period]) / period for c in closes[period:]: ema = c * k + ema * (1 - k) return ema def _tf_string_to_bybit(tf_str): """Converte '30m' -> '30', '4H' -> '240', '1h' -> '60', '3h' -> '180' per API Bybit. Gestisce case-insensitive. Default fallback: EMA50_DEFAULT_TIMEFRAME.""" tf_str = str(tf_str).strip().lower() if not tf_str: return EMA50_DEFAULT_TIMEFRAME if tf_str.endswith("m"): return tf_str[:-1] if tf_str.endswith("h"): try: return str(int(tf_str[:-1]) * 60) except ValueError: return EMA50_DEFAULT_TIMEFRAME # Already numeric or "D" for daily return tf_str or EMA50_DEFAULT_TIMEFRAME def get_strategy_timeframe(strategy, symbol): """Ritorna il timeframe Bybit ('60'=1H, '240'=4H, '30'=30m, '180'=3H) per la strategia. - vptr3: legge da VPTR_V3_ASSETS.csv colonna timeframe (via sltp_engine.load_vptr3_assets) - rettangolo: legge da rettangolo_assets.csv colonna timeframe (via rettangolo_config.load_assets) - fallback: 4H (240) se read fallisce o simbolo non in lista. NOTA Coder 19/07 13:46: load_assets() per rettangolo NON esiste in sltp_engine.py (esiste solo load_vptr3_assets), quindi importiamo da rettangolo_config.py che e' il modulo canonico per la lettura di rettangolo_assets.csv. """ try: if strategy in ("vptr3", "vptr_v3"): # Lazy import per evitare side-effect al startup del webhook try: from sltp_engine import load_vptr3_assets except ImportError: log.warning("P007 get_strategy_timeframe: sltp_engine.load_vptr3_assets non importabile, fallback %s", EMA50_DEFAULT_TIMEFRAME) return EMA50_DEFAULT_TIMEFRAME for asset in load_vptr3_assets(): if asset["symbol"] == symbol.upper() and asset.get("enabled", True): return _tf_string_to_bybit(asset["timeframe"]) # rettangolo (e default per qualsiasi altra strategia non-vptr3) try: from rettangolo_config import load_assets as _load_rett_assets except ImportError: log.warning("P007 get_strategy_timeframe: rettangolo_config.load_assets non importabile, fallback %s", EMA50_DEFAULT_TIMEFRAME) return EMA50_DEFAULT_TIMEFRAME for asset in _load_rett_assets(): if asset["symbol"] == symbol.upper() and asset.get("enabled", True): return _tf_string_to_bybit(asset["timeframe"]) except Exception as e: log.warning("P007 get_strategy_timeframe exception: %s, fallback %s", e, EMA50_DEFAULT_TIMEFRAME) return EMA50_DEFAULT_TIMEFRAME def check_regime_ema50(client, symbol, side, strategy): """Controlla P007 regime EMA 50. Ritorna (pass, reason). - pass=True: regime OK, ordine puo' procedere - pass=False: regime KO, SKIP-REGIME - Se fetch klines fallisce o dati insufficienti: pass=True con reason warn (regola Mattia "MAI bloccare"). Usa candela [-2] (P010 Charter, esclude candela in formazione). fetch_ohlcv ritorna lista [ts, o, h, l, c, v]; escludiamo l'ultima (live) e poi prendiamo close[-2] dalle klines chiuse.""" if not EMA50_REGIME_ENABLED: return (True, "P007 DISABLED via kill switch") try: tf = get_strategy_timeframe(strategy, symbol) klines = client.fetch_ohlcv(symbol, tf, EMA50_LOOKBACK_CANDLES) if not klines or len(klines) < EMA50_PERIOD + 2: log.warning("P007 SKIP-CHECK: klines insufficienti per %s tf=%s (%d<%d), regime IGNORATO", symbol, tf, len(klines) if klines else 0, EMA50_PERIOD + 2) return (True, f"P007 skip-check: dati insufficienti (klines={len(klines) if klines else 0})") # Escludi ultima candela (in formazione, "live") PRIMA di prendere close[-2] closes = [float(c[4]) for c in klines[:-1]] if len(closes) < EMA50_PERIOD + 1: return (True, "P007 skip-check: chiuse insufficienti post-filtro live") ema50 = compute_ema50(closes, EMA50_PERIOD) last_close = closes[-2] # candela [-2] (P010 Charter, esclude live) if ema50 is None: return (True, "P007 skip-check: ema50 None") if side.lower() in ("buy", "long"): if last_close < ema50: return (False, f"P007 SKIP-REGIME LONG: close[-2]={last_close:.6f} < EMA50[-2]={ema50:.6f}") elif side.lower() in ("sell", "short"): if last_close > ema50: return (False, f"P007 SKIP-REGIME SHORT: close[-2]={last_close:.6f} > EMA50[-2]={ema50:.6f}") return (True, f"P007 OK: close[-2]={last_close:.6f} vs EMA50[-2]={ema50:.6f}") except Exception as e: log.warning("P007 check exception: %s, regime IGNORATO (MAI bloccare)", e) return (True, f"P007 skip-check: exception {e}") if __name__ == "__main__": main()